langchain-ceki 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Node
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tgz
|
|
5
|
+
.npm
|
|
6
|
+
.npmrc
|
|
7
|
+
npm-debug.log*
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.py[cod]
|
|
12
|
+
*$py.class
|
|
13
|
+
*.egg-info/
|
|
14
|
+
build/
|
|
15
|
+
.eggs/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
.tox/
|
|
19
|
+
|
|
20
|
+
# Env / local
|
|
21
|
+
.env
|
|
22
|
+
.env.*
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
|
|
26
|
+
# IDE
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
*.swp
|
|
30
|
+
.DS_Store
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-ceki
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tool for Ceki — real Chrome sessions for AI agents
|
|
5
|
+
Project-URL: homepage, https://ceki.me
|
|
6
|
+
Project-URL: repository, https://github.com/Ceki-me/langchain
|
|
7
|
+
Project-URL: issues, https://github.com/Ceki-me/langchain/issues
|
|
8
|
+
Author: iWedmak
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai-agent,automation,browser,ceki,chrome,langchain,tool
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# langchain-ceki
|
|
27
|
+
|
|
28
|
+
LangChain toolkit for [Ceki](https://ceki.me) — drive a real Chrome session from your LangChain agent. Structural tools that wrap [`ceki-sdk`](https://pypi.org/project/ceki-sdk/).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install langchain-ceki
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
os.environ["CEKI_API_KEY"] = "your_key_here" # or export it
|
|
41
|
+
|
|
42
|
+
from langchain_ceki import CekiToolkit
|
|
43
|
+
from langchain.agents import AgentExecutor, create_tool_calling_agent
|
|
44
|
+
|
|
45
|
+
toolkit = CekiToolkit(default_rent={"schedule_id": 4242, "mode": "main"})
|
|
46
|
+
tools = toolkit.get_tools()
|
|
47
|
+
|
|
48
|
+
agent = create_tool_calling_agent(llm, tools, prompt)
|
|
49
|
+
executor = AgentExecutor(agent=agent, tools=tools)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = await executor.ainvoke({
|
|
53
|
+
"input": (
|
|
54
|
+
"Open https://my-app.example.com, log in with the saved profile, "
|
|
55
|
+
"and return the dashboard's headline number."
|
|
56
|
+
),
|
|
57
|
+
})
|
|
58
|
+
print(result["output"])
|
|
59
|
+
finally:
|
|
60
|
+
await toolkit.aclose() # ALWAYS — leaving sessions open burns credit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Tools
|
|
64
|
+
|
|
65
|
+
| Tool | What it does |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `ceki_rent_browser` | Rent a real Chrome session and return its `session_id`. Pass it to every other tool. |
|
|
68
|
+
| `ceki_navigate` | Open a URL. |
|
|
69
|
+
| `ceki_click` | Click at viewport coordinates. Mouse jitter ON by default; `human=False` to teleport. |
|
|
70
|
+
| `ceki_type` | Type text into the focused element. Cadence + jitter ON by default. |
|
|
71
|
+
| `ceki_scroll` | Scroll by `delta_y` pixels with easing. |
|
|
72
|
+
| `ceki_screenshot` | PNG of the current viewport as base64. |
|
|
73
|
+
| `ceki_snapshot` | Screenshot + drained chat messages from the provider. |
|
|
74
|
+
| `ceki_chat_send` | Send a chat message to the human provider (e.g. ask for a captcha code). |
|
|
75
|
+
| `ceki_stop` | End the session. Always call when done. |
|
|
76
|
+
|
|
77
|
+
Both sync (`tool._run` / `tool.invoke`) and async (`tool._arun` / `tool.ainvoke`) paths are supported. The sync path is safe to call from a synchronous LangChain runnable; calling it from inside an already-running event loop raises with a clear hint to switch to `ainvoke`.
|
|
78
|
+
|
|
79
|
+
Get an API key at [ceki.me](https://ceki.me).
|
|
80
|
+
|
|
81
|
+
## Use responsibly
|
|
82
|
+
|
|
83
|
+
Use only on sites you own or have authorization to operate on.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# langchain-ceki
|
|
2
|
+
|
|
3
|
+
LangChain toolkit for [Ceki](https://ceki.me) — drive a real Chrome session from your LangChain agent. Structural tools that wrap [`ceki-sdk`](https://pypi.org/project/ceki-sdk/).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install langchain-ceki
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
os.environ["CEKI_API_KEY"] = "your_key_here" # or export it
|
|
16
|
+
|
|
17
|
+
from langchain_ceki import CekiToolkit
|
|
18
|
+
from langchain.agents import AgentExecutor, create_tool_calling_agent
|
|
19
|
+
|
|
20
|
+
toolkit = CekiToolkit(default_rent={"schedule_id": 4242, "mode": "main"})
|
|
21
|
+
tools = toolkit.get_tools()
|
|
22
|
+
|
|
23
|
+
agent = create_tool_calling_agent(llm, tools, prompt)
|
|
24
|
+
executor = AgentExecutor(agent=agent, tools=tools)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = await executor.ainvoke({
|
|
28
|
+
"input": (
|
|
29
|
+
"Open https://my-app.example.com, log in with the saved profile, "
|
|
30
|
+
"and return the dashboard's headline number."
|
|
31
|
+
),
|
|
32
|
+
})
|
|
33
|
+
print(result["output"])
|
|
34
|
+
finally:
|
|
35
|
+
await toolkit.aclose() # ALWAYS — leaving sessions open burns credit
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | What it does |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `ceki_rent_browser` | Rent a real Chrome session and return its `session_id`. Pass it to every other tool. |
|
|
43
|
+
| `ceki_navigate` | Open a URL. |
|
|
44
|
+
| `ceki_click` | Click at viewport coordinates. Mouse jitter ON by default; `human=False` to teleport. |
|
|
45
|
+
| `ceki_type` | Type text into the focused element. Cadence + jitter ON by default. |
|
|
46
|
+
| `ceki_scroll` | Scroll by `delta_y` pixels with easing. |
|
|
47
|
+
| `ceki_screenshot` | PNG of the current viewport as base64. |
|
|
48
|
+
| `ceki_snapshot` | Screenshot + drained chat messages from the provider. |
|
|
49
|
+
| `ceki_chat_send` | Send a chat message to the human provider (e.g. ask for a captcha code). |
|
|
50
|
+
| `ceki_stop` | End the session. Always call when done. |
|
|
51
|
+
|
|
52
|
+
Both sync (`tool._run` / `tool.invoke`) and async (`tool._arun` / `tool.ainvoke`) paths are supported. The sync path is safe to call from a synchronous LangChain runnable; calling it from inside an already-running event loop raises with a clear hint to switch to `ainvoke`.
|
|
53
|
+
|
|
54
|
+
Get an API key at [ceki.me](https://ceki.me).
|
|
55
|
+
|
|
56
|
+
## Use responsibly
|
|
57
|
+
|
|
58
|
+
Use only on sites you own or have authorization to operate on.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-ceki"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tool for Ceki — real Chrome sessions for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "iWedmak" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = ["langchain", "tool", "browser", "ai-agent", "automation", "ceki", "chrome"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"langchain-core>=0.3.0",
|
|
26
|
+
"httpx>=0.27.0",
|
|
27
|
+
"pydantic>=2.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
homepage = "https://ceki.me"
|
|
32
|
+
repository = "https://github.com/Ceki-me/langchain"
|
|
33
|
+
issues = "https://github.com/Ceki-me/langchain/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = [
|
|
37
|
+
"pytest>=8.0.0",
|
|
38
|
+
"pytest-asyncio>=0.23.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build]
|
|
42
|
+
include = [
|
|
43
|
+
"src/langchain_ceki/**/*.py",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/langchain_ceki"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""LangChain toolkit for Ceki — drive a real Chrome session from your agent.
|
|
2
|
+
|
|
3
|
+
Use only on sites you own or have authorization to operate on.
|
|
4
|
+
"""
|
|
5
|
+
from langchain_ceki.toolkit import CekiToolkit, get_ceki_tools
|
|
6
|
+
|
|
7
|
+
__all__ = ["CekiToolkit", "get_ceki_tools"]
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""LangChain toolkit for Ceki — structural tools backed by the async ceki-sdk.
|
|
2
|
+
|
|
3
|
+
Architecture (Variant C, fixed with the Ceki backend team):
|
|
4
|
+
|
|
5
|
+
This package is a THIN WRAPPER. The agent's own LLM decides which low-level
|
|
6
|
+
tool to call (rent_browser, navigate, click, type, ...) and in what order.
|
|
7
|
+
There is no server-side natural-language endpoint and no LLM lives here.
|
|
8
|
+
|
|
9
|
+
Every tool exposes both `_run` (sync) and `_arun` (async). `_run` reuses the
|
|
10
|
+
toolkit's own asyncio loop when one is already running; otherwise it spins
|
|
11
|
+
up a private loop. This means the toolkit is safe to use from a synchronous
|
|
12
|
+
LangChain runnable AND inside an async agent.
|
|
13
|
+
|
|
14
|
+
Use only on sites you own or have authorization to operate on.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
from typing import Any, ClassVar, Optional
|
|
24
|
+
|
|
25
|
+
from ceki_sdk import Client
|
|
26
|
+
from ceki_sdk._browser import Browser
|
|
27
|
+
from langchain_core.tools import BaseTool
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_API_URL = "https://api.ceki.me"
|
|
32
|
+
DEFAULT_RELAY_URL = "wss://relay.ceki.me"
|
|
33
|
+
DEFAULT_CHAT_URL = "https://chat.ceki.me"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
# Async-bridge — lets sync `_run` reach the toolkit's coroutines without
|
|
38
|
+
# blocking an enclosing event loop. If the caller is sync (no running loop),
|
|
39
|
+
# we spin up a dedicated background loop in a daemon thread and reuse it.
|
|
40
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
41
|
+
class _AsyncBridge:
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
44
|
+
self._thread: Optional[threading.Thread] = None
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
def _ensure(self) -> asyncio.AbstractEventLoop:
|
|
48
|
+
with self._lock:
|
|
49
|
+
if self._loop is None or not self._loop.is_running():
|
|
50
|
+
self._loop = asyncio.new_event_loop()
|
|
51
|
+
self._thread = threading.Thread(
|
|
52
|
+
target=self._loop.run_forever, daemon=True, name="ceki-toolkit-loop"
|
|
53
|
+
)
|
|
54
|
+
self._thread.start()
|
|
55
|
+
return self._loop
|
|
56
|
+
|
|
57
|
+
def run(self, coro: Any) -> Any:
|
|
58
|
+
# If we're already inside a running event loop, the caller is async
|
|
59
|
+
# and should have hit `_arun` directly. Falling through to sync is
|
|
60
|
+
# almost always a bug; raise so the agent author notices.
|
|
61
|
+
try:
|
|
62
|
+
asyncio.get_running_loop()
|
|
63
|
+
except RuntimeError:
|
|
64
|
+
pass
|
|
65
|
+
else:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"CekiToolkit sync tool was invoked from inside a running event loop. "
|
|
68
|
+
"Use the async LangChain runnable path (`ainvoke`) so the tool's "
|
|
69
|
+
"_arun() is called instead."
|
|
70
|
+
)
|
|
71
|
+
loop = self._ensure()
|
|
72
|
+
return asyncio.run_coroutine_threadsafe(coro, loop).result()
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
with self._lock:
|
|
76
|
+
loop = self._loop
|
|
77
|
+
self._loop = None
|
|
78
|
+
self._thread = None
|
|
79
|
+
if loop and loop.is_running():
|
|
80
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
84
|
+
# Toolkit
|
|
85
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
86
|
+
class CekiToolkit:
|
|
87
|
+
"""Container that owns the Ceki Client + active Browser sessions.
|
|
88
|
+
|
|
89
|
+
Build it once per agent run, call :meth:`get_tools` to get the structural
|
|
90
|
+
toolkit, and remember to ``await toolkit.aclose()`` (or ``toolkit.close()``)
|
|
91
|
+
in a ``finally`` block.
|
|
92
|
+
|
|
93
|
+
Example::
|
|
94
|
+
|
|
95
|
+
from langchain_ceki import CekiToolkit
|
|
96
|
+
|
|
97
|
+
toolkit = CekiToolkit(default_rent={"schedule_id": 4242})
|
|
98
|
+
tools = toolkit.get_tools()
|
|
99
|
+
# pass `tools` to any LangChain agent
|
|
100
|
+
# ...
|
|
101
|
+
await toolkit.aclose()
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
api_url: Optional[str] = None,
|
|
109
|
+
relay_url: Optional[str] = None,
|
|
110
|
+
chat_url: Optional[str] = None,
|
|
111
|
+
basic_auth: Optional[tuple[str, str]] = None,
|
|
112
|
+
default_rent: Optional[dict[str, Any]] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
key = api_key or os.environ.get("CEKI_API_KEY", "")
|
|
115
|
+
if not key:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"CEKI_API_KEY not set. Sign up at https://ceki.me and export the API key."
|
|
118
|
+
)
|
|
119
|
+
self._api_key = key
|
|
120
|
+
self._api_url = api_url or os.environ.get("CEKI_API_URL") or DEFAULT_API_URL
|
|
121
|
+
self._relay_url = relay_url or os.environ.get("CEKI_RELAY_URL") or DEFAULT_RELAY_URL
|
|
122
|
+
self._chat_url = chat_url or os.environ.get("CEKI_CHAT_URL") or DEFAULT_CHAT_URL
|
|
123
|
+
self._basic_auth = basic_auth
|
|
124
|
+
self._default_rent: dict[str, Any] = dict(default_rent or {})
|
|
125
|
+
self._client: Optional[Client] = None
|
|
126
|
+
self._sessions: dict[str, Browser] = {}
|
|
127
|
+
self._bridge = _AsyncBridge()
|
|
128
|
+
self._connect_lock = asyncio.Lock()
|
|
129
|
+
|
|
130
|
+
# ─── lifecycle ────────────────────────────────────────────────────
|
|
131
|
+
async def _aget_client(self) -> Client:
|
|
132
|
+
if self._client is not None:
|
|
133
|
+
return self._client
|
|
134
|
+
async with self._connect_lock:
|
|
135
|
+
if self._client is None:
|
|
136
|
+
client = Client(
|
|
137
|
+
api_key=self._api_key,
|
|
138
|
+
relay_url=self._relay_url,
|
|
139
|
+
api_url=self._api_url,
|
|
140
|
+
chat_url=self._chat_url,
|
|
141
|
+
basic_auth=self._basic_auth,
|
|
142
|
+
)
|
|
143
|
+
await client._connect() # type: ignore[attr-defined]
|
|
144
|
+
self._client = client
|
|
145
|
+
return self._client
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
"""Close every open session and disconnect from the relay."""
|
|
149
|
+
sessions = list(self._sessions.values())
|
|
150
|
+
self._sessions.clear()
|
|
151
|
+
for s in sessions:
|
|
152
|
+
try:
|
|
153
|
+
await s.close()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
if self._client is not None:
|
|
157
|
+
try:
|
|
158
|
+
await self._client.disconnect()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
self._client = None
|
|
162
|
+
self._bridge.close()
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Sync variant of :meth:`aclose` for non-async agents."""
|
|
166
|
+
self._bridge.run(self.aclose())
|
|
167
|
+
|
|
168
|
+
# ─── session bookkeeping ──────────────────────────────────────────
|
|
169
|
+
def _require_session(self, session_id: str) -> Browser:
|
|
170
|
+
b = self._sessions.get(session_id)
|
|
171
|
+
if b is None:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"session_id={session_id!r} is not active. "
|
|
174
|
+
"Call ceki_rent_browser first or pass an id returned by it."
|
|
175
|
+
)
|
|
176
|
+
return b
|
|
177
|
+
|
|
178
|
+
# ─── public API ───────────────────────────────────────────────────
|
|
179
|
+
def get_tools(self) -> list[BaseTool]:
|
|
180
|
+
return [
|
|
181
|
+
CekiRentBrowserTool(toolkit=self),
|
|
182
|
+
CekiNavigateTool(toolkit=self),
|
|
183
|
+
CekiClickTool(toolkit=self),
|
|
184
|
+
CekiTypeTool(toolkit=self),
|
|
185
|
+
CekiScrollTool(toolkit=self),
|
|
186
|
+
CekiScreenshotTool(toolkit=self),
|
|
187
|
+
CekiSnapshotTool(toolkit=self),
|
|
188
|
+
CekiChatSendTool(toolkit=self),
|
|
189
|
+
CekiStopTool(toolkit=self),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_ceki_tools(**kwargs: Any) -> list[BaseTool]:
|
|
194
|
+
"""Convenience: build a toolkit and return its tools in one line.
|
|
195
|
+
|
|
196
|
+
The caller owns the toolkit and is responsible for closing it. To close,
|
|
197
|
+
grab it off any tool: ``tools[0].toolkit.close()``.
|
|
198
|
+
"""
|
|
199
|
+
tk = CekiToolkit(**kwargs)
|
|
200
|
+
return tk.get_tools()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
204
|
+
# Tool base
|
|
205
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
206
|
+
class _CekiToolBase(BaseTool):
|
|
207
|
+
"""Shared plumbing: ``_run`` defers to ``_arun`` through the bridge."""
|
|
208
|
+
|
|
209
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
210
|
+
|
|
211
|
+
toolkit: CekiToolkit = Field(..., exclude=True)
|
|
212
|
+
|
|
213
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
214
|
+
return self.toolkit._bridge.run(self._arun(*args, **kwargs))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
218
|
+
# Tool input schemas
|
|
219
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
220
|
+
class _RentInput(BaseModel):
|
|
221
|
+
schedule_id: Optional[int] = Field(
|
|
222
|
+
default=None,
|
|
223
|
+
description="Specific schedule_id to rent. Omit to take the toolkit default.",
|
|
224
|
+
)
|
|
225
|
+
mode: Optional[str] = Field(
|
|
226
|
+
default=None,
|
|
227
|
+
description="Profile mode: 'main' or 'incognito'.",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _SessionOnly(BaseModel):
|
|
232
|
+
session_id: str = Field(..., description="Session id returned by ceki_rent_browser.")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _NavigateInput(_SessionOnly):
|
|
236
|
+
url: str = Field(..., description="Absolute http/https URL to open.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _ClickInput(_SessionOnly):
|
|
240
|
+
x: float
|
|
241
|
+
y: float
|
|
242
|
+
human: Optional[bool] = Field(
|
|
243
|
+
default=None,
|
|
244
|
+
description="Pass false to skip mouse-jitter humanization for this call.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _TypeInput(_SessionOnly):
|
|
249
|
+
text: str
|
|
250
|
+
human: Optional[bool] = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _ScrollInput(_SessionOnly):
|
|
254
|
+
delta_y: float = Field(..., description="Vertical scroll delta in CSS pixels (negative = up).")
|
|
255
|
+
x: Optional[int] = 0
|
|
256
|
+
y: Optional[int] = 0
|
|
257
|
+
human: Optional[bool] = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _ChatSendInput(_SessionOnly):
|
|
261
|
+
text: str = Field(..., description="Message to send to the human provider via chat.")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
265
|
+
# Tools
|
|
266
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
267
|
+
class CekiRentBrowserTool(_CekiToolBase):
|
|
268
|
+
name: ClassVar[str] = "ceki_rent_browser"
|
|
269
|
+
description: ClassVar[str] = (
|
|
270
|
+
"Rent a real Chrome session from the Ceki marketplace. Returns a session_id "
|
|
271
|
+
"you must pass to every other ceki_* tool. Call this BEFORE navigate/click/type/etc."
|
|
272
|
+
)
|
|
273
|
+
args_schema: ClassVar[type[BaseModel]] = _RentInput
|
|
274
|
+
|
|
275
|
+
async def _arun(
|
|
276
|
+
self,
|
|
277
|
+
schedule_id: Optional[int] = None,
|
|
278
|
+
mode: Optional[str] = None,
|
|
279
|
+
**_: Any,
|
|
280
|
+
) -> str:
|
|
281
|
+
client = await self.toolkit._aget_client()
|
|
282
|
+
sid = schedule_id or self.toolkit._default_rent.get("schedule_id")
|
|
283
|
+
if sid is None:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"ceki_rent_browser: schedule_id is required. Pass it explicitly or "
|
|
286
|
+
"configure CekiToolkit(default_rent={'schedule_id': ...})."
|
|
287
|
+
)
|
|
288
|
+
m = mode or self.toolkit._default_rent.get("mode") or "incognito"
|
|
289
|
+
if m not in ("incognito", "main"):
|
|
290
|
+
raise ValueError(f"mode must be 'main' or 'incognito', got {m!r}")
|
|
291
|
+
browser = await client.rent(sid, mode=m)
|
|
292
|
+
self.toolkit._sessions[browser.session_id] = browser
|
|
293
|
+
return json.dumps(
|
|
294
|
+
{"session_id": browser.session_id, "schedule_id": sid, "mode": m}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class CekiNavigateTool(_CekiToolBase):
|
|
299
|
+
name: ClassVar[str] = "ceki_navigate"
|
|
300
|
+
description: ClassVar[str] = (
|
|
301
|
+
"Open a URL in the rented Chrome session. Waits up to 30s for navigation."
|
|
302
|
+
)
|
|
303
|
+
args_schema: ClassVar[type[BaseModel]] = _NavigateInput
|
|
304
|
+
|
|
305
|
+
async def _arun(self, session_id: str, url: str, **_: Any) -> str:
|
|
306
|
+
b = self.toolkit._require_session(session_id)
|
|
307
|
+
res = await b.navigate(url)
|
|
308
|
+
return json.dumps({"ok": True, "url": res.get("url") if isinstance(res, dict) else url})
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class CekiClickTool(_CekiToolBase):
|
|
312
|
+
name: ClassVar[str] = "ceki_click"
|
|
313
|
+
description: ClassVar[str] = (
|
|
314
|
+
"Click at viewport coordinates in the rented session. Mouse jitter is ON by default; "
|
|
315
|
+
"pass human=false to teleport."
|
|
316
|
+
)
|
|
317
|
+
args_schema: ClassVar[type[BaseModel]] = _ClickInput
|
|
318
|
+
|
|
319
|
+
async def _arun(
|
|
320
|
+
self, session_id: str, x: float, y: float, human: Optional[bool] = None, **_: Any
|
|
321
|
+
) -> str:
|
|
322
|
+
b = self.toolkit._require_session(session_id)
|
|
323
|
+
await b.click(x, y, human=human)
|
|
324
|
+
return json.dumps({"ok": True})
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class CekiTypeTool(_CekiToolBase):
|
|
328
|
+
name: ClassVar[str] = "ceki_type"
|
|
329
|
+
description: ClassVar[str] = (
|
|
330
|
+
"Type text into the currently-focused element of the rented session. "
|
|
331
|
+
"Click an input first; humanization (cadence + jitter) is ON by default."
|
|
332
|
+
)
|
|
333
|
+
args_schema: ClassVar[type[BaseModel]] = _TypeInput
|
|
334
|
+
|
|
335
|
+
async def _arun(
|
|
336
|
+
self, session_id: str, text: str, human: Optional[bool] = None, **_: Any
|
|
337
|
+
) -> str:
|
|
338
|
+
b = self.toolkit._require_session(session_id)
|
|
339
|
+
await b.type(text, human=human)
|
|
340
|
+
return json.dumps({"ok": True})
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class CekiScrollTool(_CekiToolBase):
|
|
344
|
+
name: ClassVar[str] = "ceki_scroll"
|
|
345
|
+
description: ClassVar[str] = (
|
|
346
|
+
"Scroll the rented session by delta_y CSS pixels. Easing is ON by default; "
|
|
347
|
+
"pass human=false for a raw CDP wheel."
|
|
348
|
+
)
|
|
349
|
+
args_schema: ClassVar[type[BaseModel]] = _ScrollInput
|
|
350
|
+
|
|
351
|
+
async def _arun(
|
|
352
|
+
self,
|
|
353
|
+
session_id: str,
|
|
354
|
+
delta_y: float,
|
|
355
|
+
x: Optional[int] = 0,
|
|
356
|
+
y: Optional[int] = 0,
|
|
357
|
+
human: Optional[bool] = None,
|
|
358
|
+
**_: Any,
|
|
359
|
+
) -> str:
|
|
360
|
+
b = self.toolkit._require_session(session_id)
|
|
361
|
+
await b.scroll(x=int(x or 0), y=int(y or 0), delta_y=int(delta_y), human=human)
|
|
362
|
+
return json.dumps({"ok": True})
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class CekiScreenshotTool(_CekiToolBase):
|
|
366
|
+
name: ClassVar[str] = "ceki_screenshot"
|
|
367
|
+
description: ClassVar[str] = (
|
|
368
|
+
"Take a PNG screenshot of the rented session's current viewport. Returns base64."
|
|
369
|
+
)
|
|
370
|
+
args_schema: ClassVar[type[BaseModel]] = _SessionOnly
|
|
371
|
+
|
|
372
|
+
async def _arun(self, session_id: str, **_: Any) -> str:
|
|
373
|
+
b = self.toolkit._require_session(session_id)
|
|
374
|
+
shot = await b.screenshot()
|
|
375
|
+
if isinstance(shot, bytes):
|
|
376
|
+
b64 = base64.b64encode(shot).decode("ascii")
|
|
377
|
+
elif isinstance(shot, dict) and "data" in shot:
|
|
378
|
+
b64 = shot["data"]
|
|
379
|
+
else:
|
|
380
|
+
raise RuntimeError(f"unexpected screenshot shape: {type(shot).__name__}")
|
|
381
|
+
return json.dumps(
|
|
382
|
+
{"ok": True, "mime": "image/png", "base64": b64, "bytes": (len(b64) * 3) // 4}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class CekiSnapshotTool(_CekiToolBase):
|
|
387
|
+
name: ClassVar[str] = "ceki_snapshot"
|
|
388
|
+
description: ClassVar[str] = (
|
|
389
|
+
"Take a screenshot AND drain pending chat messages from the provider. "
|
|
390
|
+
"Returns a JSON blob with both."
|
|
391
|
+
)
|
|
392
|
+
args_schema: ClassVar[type[BaseModel]] = _SessionOnly
|
|
393
|
+
|
|
394
|
+
async def _arun(self, session_id: str, **_: Any) -> str:
|
|
395
|
+
b = self.toolkit._require_session(session_id)
|
|
396
|
+
snap = await b.snapshot()
|
|
397
|
+
screenshot = getattr(snap, "screenshot", None)
|
|
398
|
+
if isinstance(screenshot, bytes):
|
|
399
|
+
screenshot = base64.b64encode(screenshot).decode("ascii")
|
|
400
|
+
return json.dumps(
|
|
401
|
+
{
|
|
402
|
+
"ok": True,
|
|
403
|
+
"screenshot_base64": screenshot,
|
|
404
|
+
"chat": getattr(snap, "chat", None) or [],
|
|
405
|
+
},
|
|
406
|
+
default=str,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class CekiChatSendTool(_CekiToolBase):
|
|
411
|
+
name: ClassVar[str] = "ceki_chat_send"
|
|
412
|
+
description: ClassVar[str] = (
|
|
413
|
+
"Send a chat message to the human provider of the rented session "
|
|
414
|
+
"(e.g. to ask for a captcha code or 2FA)."
|
|
415
|
+
)
|
|
416
|
+
args_schema: ClassVar[type[BaseModel]] = _ChatSendInput
|
|
417
|
+
|
|
418
|
+
async def _arun(self, session_id: str, text: str, **_: Any) -> str:
|
|
419
|
+
b = self.toolkit._require_session(session_id)
|
|
420
|
+
await b.chat.send(text)
|
|
421
|
+
return json.dumps({"ok": True})
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class CekiStopTool(_CekiToolBase):
|
|
425
|
+
name: ClassVar[str] = "ceki_stop"
|
|
426
|
+
description: ClassVar[str] = (
|
|
427
|
+
"End the rented Chrome session. Always call this when you're done — leaving "
|
|
428
|
+
"sessions open burns the user's credit."
|
|
429
|
+
)
|
|
430
|
+
args_schema: ClassVar[type[BaseModel]] = _SessionOnly
|
|
431
|
+
|
|
432
|
+
async def _arun(self, session_id: str, **_: Any) -> str:
|
|
433
|
+
b = self.toolkit._require_session(session_id)
|
|
434
|
+
await b.close()
|
|
435
|
+
self.toolkit._sessions.pop(session_id, None)
|
|
436
|
+
return json.dumps({"ok": True})
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
__all__ = [
|
|
440
|
+
"CekiToolkit",
|
|
441
|
+
"get_ceki_tools",
|
|
442
|
+
"CekiRentBrowserTool",
|
|
443
|
+
"CekiNavigateTool",
|
|
444
|
+
"CekiClickTool",
|
|
445
|
+
"CekiTypeTool",
|
|
446
|
+
"CekiScrollTool",
|
|
447
|
+
"CekiScreenshotTool",
|
|
448
|
+
"CekiSnapshotTool",
|
|
449
|
+
"CekiChatSendTool",
|
|
450
|
+
"CekiStopTool",
|
|
451
|
+
]
|