ceki-sdk 2.15.1__py3-none-any.whl
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.
- ceki_sdk/__init__.py +52 -0
- ceki_sdk/_browser.py +646 -0
- ceki_sdk/_captcha.py +88 -0
- ceki_sdk/_chat.py +203 -0
- ceki_sdk/_client.py +501 -0
- ceki_sdk/_config.py +19 -0
- ceki_sdk/_connect.py +32 -0
- ceki_sdk/_exceptions.py +70 -0
- ceki_sdk/_models.py +88 -0
- ceki_sdk/_profile.py +137 -0
- ceki_sdk/_state.py +44 -0
- ceki_sdk/cli.py +583 -0
- ceki_sdk/humanize/__init__.py +4 -0
- ceki_sdk/humanize/humanizer.py +56 -0
- ceki_sdk/humanize/keymap.py +70 -0
- ceki_sdk/humanize/profile.py +96 -0
- ceki_sdk/humanize/profiles/careful.json +30 -0
- ceki_sdk/humanize/profiles/natural.json +30 -0
- ceki_sdk-2.15.1.dist-info/METADATA +294 -0
- ceki_sdk-2.15.1.dist-info/RECORD +23 -0
- ceki_sdk-2.15.1.dist-info/WHEEL +4 -0
- ceki_sdk-2.15.1.dist-info/entry_points.txt +2 -0
- ceki_sdk-2.15.1.dist-info/licenses/LICENSE +21 -0
ceki_sdk/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from ._browser import Browser
|
|
2
|
+
from ._captcha import CaptchaResult
|
|
3
|
+
from ._client import Client
|
|
4
|
+
from ._connect import ConnectOptions, connect
|
|
5
|
+
from ._profile import BrowserProfile
|
|
6
|
+
from ._exceptions import (
|
|
7
|
+
AuthFailed,
|
|
8
|
+
CaptchaError,
|
|
9
|
+
CaptchaTimeoutError,
|
|
10
|
+
CdpUnrecoverable,
|
|
11
|
+
CekiError,
|
|
12
|
+
ConnectionLost,
|
|
13
|
+
InsufficientFunds,
|
|
14
|
+
NotOwner,
|
|
15
|
+
ProviderDisconnected,
|
|
16
|
+
RateLimitExceeded,
|
|
17
|
+
SessionEnded,
|
|
18
|
+
SessionExpired,
|
|
19
|
+
SessionNotFound,
|
|
20
|
+
)
|
|
21
|
+
from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot
|
|
22
|
+
from .humanize import HumanProfile
|
|
23
|
+
|
|
24
|
+
__version__ = "2.15.1"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"connect",
|
|
27
|
+
"ConnectOptions",
|
|
28
|
+
"Client",
|
|
29
|
+
"Browser",
|
|
30
|
+
"BrowserOption",
|
|
31
|
+
"Match",
|
|
32
|
+
"ChatMessage",
|
|
33
|
+
"ReadReceipt",
|
|
34
|
+
"RateLimitExceeded",
|
|
35
|
+
"InsufficientFunds",
|
|
36
|
+
"SessionEnded",
|
|
37
|
+
"CdpUnrecoverable",
|
|
38
|
+
"AuthFailed",
|
|
39
|
+
"ConnectionLost",
|
|
40
|
+
"ProviderDisconnected",
|
|
41
|
+
"SessionNotFound",
|
|
42
|
+
"SessionExpired",
|
|
43
|
+
"NotOwner",
|
|
44
|
+
"SessionInfo",
|
|
45
|
+
"Snapshot",
|
|
46
|
+
"BrowserProfile",
|
|
47
|
+
"CekiError",
|
|
48
|
+
"HumanProfile",
|
|
49
|
+
"CaptchaResult",
|
|
50
|
+
"CaptchaError",
|
|
51
|
+
"CaptchaTimeoutError",
|
|
52
|
+
]
|
ceki_sdk/_browser.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Literal, cast
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .humanize import HumanProfile, Humanizer
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ._client import Client
|
|
19
|
+
from ._captcha import CaptchaResult
|
|
20
|
+
from ._exceptions import (
|
|
21
|
+
CaptchaTimeoutError,
|
|
22
|
+
CdpUnrecoverable,
|
|
23
|
+
InsufficientFunds,
|
|
24
|
+
ProviderDisconnected,
|
|
25
|
+
RateLimitExceeded,
|
|
26
|
+
SessionEnded,
|
|
27
|
+
)
|
|
28
|
+
from ._models import Match, Snapshot
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
33
|
+
TabOpenedCallback = Callable[[str], Awaitable[None]]
|
|
34
|
+
SimpleCallback = Callable[[], Awaitable[None]]
|
|
35
|
+
UserEventCallback = Callable[[list[dict[str, Any]]], Awaitable[None]]
|
|
36
|
+
|
|
37
|
+
_ERROR_TERMINAL = {-1011, -1012, -1015, -1018}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_human(human) -> Humanizer | None:
|
|
41
|
+
if os.environ.get("CEKI_HUMAN_DISABLE", "").lower() in ("1", "true", "yes"):
|
|
42
|
+
return None
|
|
43
|
+
if human is None:
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(human, HumanProfile):
|
|
46
|
+
return Humanizer(human)
|
|
47
|
+
if isinstance(human, dict):
|
|
48
|
+
return Humanizer(HumanProfile.from_dict(human))
|
|
49
|
+
if isinstance(human, (str, Path)):
|
|
50
|
+
s = str(human)
|
|
51
|
+
if s in ("natural", "careful"):
|
|
52
|
+
return Humanizer(HumanProfile.load_preset(s))
|
|
53
|
+
return Humanizer(HumanProfile.load(s))
|
|
54
|
+
raise ValueError(f"Invalid human profile: {human!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Browser:
|
|
58
|
+
def __init__(self, client: "Client", match: Match, *, human="natural") -> None:
|
|
59
|
+
self._client = client
|
|
60
|
+
self._match = match
|
|
61
|
+
self._cdp_counter = 0
|
|
62
|
+
self._pending_cdp: dict[int, asyncio.Future[Any]] = {}
|
|
63
|
+
self._event_callbacks: list[EventCallback] = []
|
|
64
|
+
self._tab_opened_callbacks: list[TabOpenedCallback] = []
|
|
65
|
+
self._provider_disconnected_callbacks: list[SimpleCallback] = []
|
|
66
|
+
self._provider_reconnected_callbacks: list[SimpleCallback] = []
|
|
67
|
+
self._user_event_callbacks: list[UserEventCallback] = []
|
|
68
|
+
self._ended = asyncio.Event()
|
|
69
|
+
self._ended_reason: str | None = None
|
|
70
|
+
|
|
71
|
+
from ._chat import BrowserChat
|
|
72
|
+
from ._profile import BrowserProfile
|
|
73
|
+
|
|
74
|
+
self.chat = BrowserChat(self)
|
|
75
|
+
self.profile = BrowserProfile(self)
|
|
76
|
+
|
|
77
|
+
env_profile = os.environ.get("CEKI_HUMAN_PROFILE")
|
|
78
|
+
env_path = os.environ.get("CEKI_HUMAN_PROFILE_PATH")
|
|
79
|
+
if human == "natural" and env_profile:
|
|
80
|
+
human = env_profile
|
|
81
|
+
elif human == "natural" and env_path:
|
|
82
|
+
human = env_path
|
|
83
|
+
self._humanizer = _resolve_human(human)
|
|
84
|
+
self._last_pointer: tuple[int, int] | None = None
|
|
85
|
+
self._last_seen_ts: str | None = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def session_id(self) -> str:
|
|
89
|
+
return self._match.session_id
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def browser_id(self) -> int:
|
|
93
|
+
return self._match.schedule_id
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def schedule_id(self) -> int:
|
|
97
|
+
return self._match.schedule_id
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def chat_topic_id(self) -> str | None:
|
|
101
|
+
return self._match.chat_topic_id
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def browser_info(self) -> dict[str, Any]:
|
|
105
|
+
return self._match.browser_info
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def provider_user_id(self) -> int | None:
|
|
109
|
+
return self._match.provider_user_id
|
|
110
|
+
|
|
111
|
+
async def send(self, cdp: dict[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
|
|
112
|
+
if self._ended.is_set():
|
|
113
|
+
raise SessionEnded(self._ended_reason or "ended")
|
|
114
|
+
cdp_id = self._cdp_counter
|
|
115
|
+
self._cdp_counter += 1
|
|
116
|
+
loop = asyncio.get_event_loop()
|
|
117
|
+
fut: asyncio.Future[Any] = loop.create_future()
|
|
118
|
+
self._pending_cdp[cdp_id] = fut
|
|
119
|
+
try:
|
|
120
|
+
await self._client._ws_send(
|
|
121
|
+
{
|
|
122
|
+
"type": "cdp",
|
|
123
|
+
"session_id": self.session_id,
|
|
124
|
+
"id": cdp_id,
|
|
125
|
+
"method": cdp["method"],
|
|
126
|
+
"params": cdp.get("params", {}),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
|
130
|
+
return result
|
|
131
|
+
finally:
|
|
132
|
+
self._pending_cdp.pop(cdp_id, None)
|
|
133
|
+
|
|
134
|
+
def on_event(self, callback: EventCallback) -> None:
|
|
135
|
+
self._event_callbacks.append(callback)
|
|
136
|
+
|
|
137
|
+
def on_tab_opened(self, callback: TabOpenedCallback) -> None:
|
|
138
|
+
self._tab_opened_callbacks.append(callback)
|
|
139
|
+
|
|
140
|
+
def on_provider_disconnected(self, callback: SimpleCallback) -> None:
|
|
141
|
+
self._provider_disconnected_callbacks.append(callback)
|
|
142
|
+
|
|
143
|
+
def on_provider_reconnected(self, callback: SimpleCallback) -> None:
|
|
144
|
+
self._provider_reconnected_callbacks.append(callback)
|
|
145
|
+
|
|
146
|
+
def on_user_event(self, callback: UserEventCallback) -> None:
|
|
147
|
+
self._user_event_callbacks.append(callback)
|
|
148
|
+
|
|
149
|
+
async def switch_tab(self) -> None:
|
|
150
|
+
await self._client._ws_send({"type": "switch_tab", "session_id": self.session_id})
|
|
151
|
+
|
|
152
|
+
async def configure(self, *, masking_mode: str | None = None, **kwargs: Any) -> None:
|
|
153
|
+
payload: dict[str, Any] = {"type": "session.configure", "session_id": self.session_id}
|
|
154
|
+
if masking_mode is not None:
|
|
155
|
+
payload["masking_mode"] = masking_mode
|
|
156
|
+
payload.update(kwargs)
|
|
157
|
+
await self._client._ws_send(payload)
|
|
158
|
+
|
|
159
|
+
async def close(self, *, timeout: float = 10.0) -> None:
|
|
160
|
+
if self._ended.is_set():
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
await self._client._ws_send(
|
|
164
|
+
{"type": "session.end", "session_id": self.session_id, "reason": "user_stop"}
|
|
165
|
+
)
|
|
166
|
+
await asyncio.wait_for(self._ended.wait(), timeout=timeout)
|
|
167
|
+
except asyncio.TimeoutError:
|
|
168
|
+
for fut in self._pending_cdp.values():
|
|
169
|
+
if not fut.done():
|
|
170
|
+
fut.cancel()
|
|
171
|
+
self._pending_cdp.clear()
|
|
172
|
+
self._ended.set()
|
|
173
|
+
self._ended_reason = "user_stop"
|
|
174
|
+
finally:
|
|
175
|
+
self._client._active_browsers.pop(self.session_id, None)
|
|
176
|
+
|
|
177
|
+
async def release(self, *, timeout: float = 10.0) -> None:
|
|
178
|
+
"""Alias for :meth:`close` — завершить аренду браузера."""
|
|
179
|
+
await self.close(timeout=timeout)
|
|
180
|
+
|
|
181
|
+
async def wait_until_ended(self) -> str:
|
|
182
|
+
await self._ended.wait()
|
|
183
|
+
return self._ended_reason or "unknown"
|
|
184
|
+
|
|
185
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
186
|
+
# High-level browser actions (with optional human-like timing)
|
|
187
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async def navigate(self, url: str, *, timeout: float = 30.0) -> dict:
|
|
190
|
+
if self._humanizer:
|
|
191
|
+
await self._humanizer.before("navigate")
|
|
192
|
+
result = await self.send({"method": "Page.navigate", "params": {"url": url}}, timeout=timeout)
|
|
193
|
+
if self._humanizer:
|
|
194
|
+
await self._humanizer.after("navigate")
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
async def click(self, x: int | float, y: int | float) -> None:
|
|
198
|
+
if self._humanizer:
|
|
199
|
+
await self._humanizer.before("click")
|
|
200
|
+
await self.send({"method": "Input.dispatchMouseEvent", "params": {
|
|
201
|
+
"type": "mousePressed", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
|
|
202
|
+
}})
|
|
203
|
+
await self.send({"method": "Input.dispatchMouseEvent", "params": {
|
|
204
|
+
"type": "mouseReleased", "x": int(x), "y": int(y), "button": "left", "clickCount": 1,
|
|
205
|
+
}})
|
|
206
|
+
self._last_pointer = (int(x), int(y))
|
|
207
|
+
if self._humanizer:
|
|
208
|
+
await self._humanizer.after("click")
|
|
209
|
+
|
|
210
|
+
async def _send_keystroke(self, char: str) -> None:
|
|
211
|
+
from .humanize.keymap import keymap_for_char
|
|
212
|
+
mapping = keymap_for_char(char)
|
|
213
|
+
if mapping is None:
|
|
214
|
+
await self.send({"method": "Input.insertText", "params": {"text": char}})
|
|
215
|
+
log.warning("Non-ASCII char %r: falling back to Input.insertText", char)
|
|
216
|
+
return
|
|
217
|
+
code, key, vk, needs_shift = mapping
|
|
218
|
+
if needs_shift:
|
|
219
|
+
await self.send({"method": "Input.dispatchKeyEvent", "params": {
|
|
220
|
+
"type": "keyDown", "key": "Shift", "code": "ShiftLeft",
|
|
221
|
+
"windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16,
|
|
222
|
+
}})
|
|
223
|
+
await self.send({"method": "Input.dispatchKeyEvent", "params": {
|
|
224
|
+
"type": "keyDown", "key": key, "code": code,
|
|
225
|
+
"text": char, "unmodifiedText": char.lower() if needs_shift else char,
|
|
226
|
+
"windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
|
|
227
|
+
**({"modifiers": 8} if needs_shift else {}),
|
|
228
|
+
}})
|
|
229
|
+
await self.send({"method": "Input.dispatchKeyEvent", "params": {
|
|
230
|
+
"type": "keyUp", "key": key, "code": code,
|
|
231
|
+
"windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
|
|
232
|
+
**({"modifiers": 8} if needs_shift else {}),
|
|
233
|
+
}})
|
|
234
|
+
if needs_shift:
|
|
235
|
+
await self.send({"method": "Input.dispatchKeyEvent", "params": {
|
|
236
|
+
"type": "keyUp", "key": "Shift", "code": "ShiftLeft",
|
|
237
|
+
"windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16,
|
|
238
|
+
}})
|
|
239
|
+
|
|
240
|
+
async def type(self, text: str) -> None:
|
|
241
|
+
if self._humanizer:
|
|
242
|
+
if self._last_pointer is not None:
|
|
243
|
+
await self.click(*self._last_pointer)
|
|
244
|
+
else:
|
|
245
|
+
log.debug("type() called with humanizer but no last_pointer; falling back to plain insertText")
|
|
246
|
+
await self._humanizer.before("type")
|
|
247
|
+
async for char, delay_ms in self._humanizer.humanize_text(text):
|
|
248
|
+
await self._send_keystroke(char)
|
|
249
|
+
if delay_ms > 0:
|
|
250
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
251
|
+
await self._humanizer.after("type")
|
|
252
|
+
else:
|
|
253
|
+
for char in text:
|
|
254
|
+
await self._send_keystroke(char)
|
|
255
|
+
|
|
256
|
+
async def scroll(
|
|
257
|
+
self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300
|
|
258
|
+
) -> None:
|
|
259
|
+
if self._humanizer:
|
|
260
|
+
await self._humanizer.before("scroll")
|
|
261
|
+
await self.send({"method": "Input.dispatchMouseEvent", "params": {
|
|
262
|
+
"type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y,
|
|
263
|
+
}})
|
|
264
|
+
self._last_pointer = (int(x), int(y))
|
|
265
|
+
if self._humanizer:
|
|
266
|
+
await self._humanizer.after("scroll")
|
|
267
|
+
|
|
268
|
+
async def screenshot(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
format: Literal["base64", "png"] = "base64",
|
|
272
|
+
full_page: bool = False,
|
|
273
|
+
) -> dict | bytes:
|
|
274
|
+
"""Take a screenshot.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
format: ``"base64"`` (default) returns CDP-shape dict, ``"png"`` returns raw PNG bytes.
|
|
278
|
+
full_page: If True, capture the entire scrollable page, not just the viewport.
|
|
279
|
+
"""
|
|
280
|
+
if format not in ("base64", "png"):
|
|
281
|
+
raise ValueError(f"Unsupported format: {format!r}. Use 'base64' or 'png'.")
|
|
282
|
+
if self._humanizer:
|
|
283
|
+
await self._humanizer.before("screenshot")
|
|
284
|
+
|
|
285
|
+
params: dict[str, Any] = {}
|
|
286
|
+
if full_page:
|
|
287
|
+
metrics = await self.send({"method": "Page.getLayoutMetrics"})
|
|
288
|
+
content = metrics.get("contentSize", {})
|
|
289
|
+
width = int(content.get("width", 0))
|
|
290
|
+
height = int(content.get("height", 0))
|
|
291
|
+
MAX_HEIGHT = 16384
|
|
292
|
+
if height > MAX_HEIGHT:
|
|
293
|
+
log.warning("full_page screenshot height=%d clamped to %d", height, MAX_HEIGHT)
|
|
294
|
+
height = MAX_HEIGHT
|
|
295
|
+
params["captureBeyondViewport"] = True
|
|
296
|
+
params["clip"] = {"x": 0, "y": 0, "width": width, "height": height, "scale": 1}
|
|
297
|
+
|
|
298
|
+
resp = await self.send({"method": "Page.captureScreenshot", "params": params})
|
|
299
|
+
if self._humanizer:
|
|
300
|
+
await self._humanizer.after("screenshot")
|
|
301
|
+
if format == "base64":
|
|
302
|
+
return resp
|
|
303
|
+
import base64 as _b64
|
|
304
|
+
data = resp.get("data", "")
|
|
305
|
+
return _b64.b64decode(data) if data else b""
|
|
306
|
+
|
|
307
|
+
async def snapshot(self) -> Snapshot:
|
|
308
|
+
from datetime import datetime, timezone
|
|
309
|
+
resp = await self.send({"method": "Page.captureScreenshot"})
|
|
310
|
+
screenshot_b64 = resp.get("data", "")
|
|
311
|
+
all_msgs = await self.chat.history(since=self._last_seen_ts)
|
|
312
|
+
if self._last_seen_ts and all_msgs:
|
|
313
|
+
all_msgs = [m for m in all_msgs if m.created_at > self._last_seen_ts]
|
|
314
|
+
if all_msgs:
|
|
315
|
+
self._last_seen_ts = all_msgs[-1].created_at
|
|
316
|
+
return Snapshot(screenshot=screenshot_b64, chat=all_msgs, ts=datetime.now(timezone.utc))
|
|
317
|
+
|
|
318
|
+
async def upload(
|
|
319
|
+
self,
|
|
320
|
+
selector: str,
|
|
321
|
+
source: str | Path | bytes,
|
|
322
|
+
*,
|
|
323
|
+
filename: str | None = None,
|
|
324
|
+
) -> dict:
|
|
325
|
+
"""Upload a file to an ``<input type="file">`` element.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
selector: CSS selector for the file input element.
|
|
329
|
+
source: File path (str/Path) or raw bytes.
|
|
330
|
+
filename: Override the filename (default: basename of path or ``upload.bin``).
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
``{"ok": True, "filename": "...", "size": N}`` on success.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
ValueError: If selector matches no element or element is not a file input.
|
|
337
|
+
"""
|
|
338
|
+
if isinstance(source, (str, Path)):
|
|
339
|
+
path = Path(source)
|
|
340
|
+
if not path.is_file():
|
|
341
|
+
raise ValueError(f"file not found: {path}")
|
|
342
|
+
data = path.read_bytes()
|
|
343
|
+
if filename is None:
|
|
344
|
+
filename = path.name
|
|
345
|
+
elif isinstance(source, bytes):
|
|
346
|
+
data = source
|
|
347
|
+
if filename is None:
|
|
348
|
+
filename = "upload.bin"
|
|
349
|
+
else:
|
|
350
|
+
raise TypeError(f"source must be str, Path, or bytes, got {type(source).__name__}")
|
|
351
|
+
|
|
352
|
+
mime_type, _ = mimetypes.guess_type(filename)
|
|
353
|
+
if mime_type is None:
|
|
354
|
+
mime_type = "application/octet-stream"
|
|
355
|
+
|
|
356
|
+
b64_data = base64.b64encode(data).decode("ascii")
|
|
357
|
+
|
|
358
|
+
js_selector = json.dumps(selector)
|
|
359
|
+
js_filename = json.dumps(filename)
|
|
360
|
+
js_mimetype = json.dumps(mime_type)
|
|
361
|
+
|
|
362
|
+
js_expr = (
|
|
363
|
+
"(function() {"
|
|
364
|
+
f"var input = document.querySelector({js_selector});"
|
|
365
|
+
"if (!input) return JSON.stringify({error: 'no input matched'});"
|
|
366
|
+
"if (input.type !== 'file') return JSON.stringify({error: 'element is not a file input'});"
|
|
367
|
+
f"var b64 = '{b64_data}';"
|
|
368
|
+
"var bin = atob(b64);"
|
|
369
|
+
"var bytes = new Uint8Array(bin.length);"
|
|
370
|
+
"for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);"
|
|
371
|
+
f"var file = new File([bytes], {js_filename}, {{type: {js_mimetype}}});"
|
|
372
|
+
"var dt = new DataTransfer();"
|
|
373
|
+
"dt.items.add(file);"
|
|
374
|
+
"input.files = dt.files;"
|
|
375
|
+
"input.dispatchEvent(new Event('change', {bubbles: true}));"
|
|
376
|
+
f"return JSON.stringify({{ok: true, filename: {js_filename}, size: bytes.length}});"
|
|
377
|
+
"})()"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
resp = await self.send(
|
|
381
|
+
{"method": "Runtime.evaluate", "params": {"expression": js_expr, "returnByValue": True}}
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
value = resp.get("result", {}).get("value", "")
|
|
385
|
+
if isinstance(value, str):
|
|
386
|
+
parsed = json.loads(value)
|
|
387
|
+
else:
|
|
388
|
+
parsed = value
|
|
389
|
+
|
|
390
|
+
if "error" in parsed:
|
|
391
|
+
raise ValueError(parsed["error"])
|
|
392
|
+
|
|
393
|
+
return parsed
|
|
394
|
+
|
|
395
|
+
def set_human(self, profile) -> "HumanProfile | None":
|
|
396
|
+
prev = self._humanizer.profile if self._humanizer else None
|
|
397
|
+
self._humanizer = _resolve_human(profile)
|
|
398
|
+
return prev
|
|
399
|
+
|
|
400
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
401
|
+
# Human action / captcha
|
|
402
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
def _api_headers(self) -> dict[str, str]:
|
|
405
|
+
headers: dict[str, str] = {"Authorization": f"Bearer {self._client.api_key}"}
|
|
406
|
+
if self._client._basic_auth:
|
|
407
|
+
creds = base64.b64encode(
|
|
408
|
+
f"{self._client._basic_auth[0]}:{self._client._basic_auth[1]}".encode()
|
|
409
|
+
).decode()
|
|
410
|
+
headers["X-Basic-Auth"] = f"Basic {creds}"
|
|
411
|
+
return headers
|
|
412
|
+
|
|
413
|
+
async def request_captcha(
|
|
414
|
+
self,
|
|
415
|
+
acceptance_timeout: float = 60,
|
|
416
|
+
completion_timeout: float = 120,
|
|
417
|
+
auto_accept: bool = True,
|
|
418
|
+
) -> CaptchaResult:
|
|
419
|
+
if acceptance_timeout < 30:
|
|
420
|
+
raise ValueError("acceptance_timeout must be >= 30 seconds")
|
|
421
|
+
if completion_timeout < 30:
|
|
422
|
+
raise ValueError("completion_timeout must be >= 30 seconds")
|
|
423
|
+
|
|
424
|
+
acceptance_timeout = min(acceptance_timeout, 300)
|
|
425
|
+
completion_timeout = min(completion_timeout, 600)
|
|
426
|
+
|
|
427
|
+
child_event_id = await self._create_captcha_event(acceptance_timeout, completion_timeout)
|
|
428
|
+
|
|
429
|
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
430
|
+
self.chat._action_queues[child_event_id] = queue
|
|
431
|
+
|
|
432
|
+
accepted = False
|
|
433
|
+
completion_deadline = datetime.now(timezone.utc) + timedelta(seconds=completion_timeout)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
deadline_accept = asyncio.get_event_loop().time() + acceptance_timeout
|
|
437
|
+
while True:
|
|
438
|
+
remaining = deadline_accept - asyncio.get_event_loop().time()
|
|
439
|
+
if remaining <= 0:
|
|
440
|
+
raise asyncio.TimeoutError()
|
|
441
|
+
action = await asyncio.wait_for(queue.get(), timeout=remaining)
|
|
442
|
+
kind = action.get("kind", "")
|
|
443
|
+
data: dict[str, Any] = action.get("data") or {}
|
|
444
|
+
|
|
445
|
+
if kind == "human_action_accepted":
|
|
446
|
+
accepted = True
|
|
447
|
+
break
|
|
448
|
+
if kind == "human_action_completed":
|
|
449
|
+
return await self._finish_captcha(
|
|
450
|
+
child_event_id, data, auto_accept, solved=True,
|
|
451
|
+
)
|
|
452
|
+
if kind in (
|
|
453
|
+
"human_action_failed",
|
|
454
|
+
"human_action_declined",
|
|
455
|
+
"human_action_withdrew",
|
|
456
|
+
):
|
|
457
|
+
return CaptchaResult(
|
|
458
|
+
solved=False,
|
|
459
|
+
child_event_id=child_event_id,
|
|
460
|
+
cancel_reason=kind.replace("human_action_", ""),
|
|
461
|
+
browser=self,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
remaining_completion = (
|
|
465
|
+
completion_deadline - datetime.now(timezone.utc)
|
|
466
|
+
).total_seconds()
|
|
467
|
+
while True:
|
|
468
|
+
if remaining_completion <= 0:
|
|
469
|
+
raise asyncio.TimeoutError()
|
|
470
|
+
action = await asyncio.wait_for(
|
|
471
|
+
queue.get(), timeout=remaining_completion,
|
|
472
|
+
)
|
|
473
|
+
kind = action.get("kind", "")
|
|
474
|
+
data = action.get("data") or {}
|
|
475
|
+
|
|
476
|
+
if kind == "human_action_completed":
|
|
477
|
+
return await self._finish_captcha(
|
|
478
|
+
child_event_id, data, auto_accept, solved=True,
|
|
479
|
+
)
|
|
480
|
+
if kind in ("human_action_failed", "human_action_withdrew"):
|
|
481
|
+
return CaptchaResult(
|
|
482
|
+
solved=False,
|
|
483
|
+
child_event_id=child_event_id,
|
|
484
|
+
cancel_reason=kind.replace("human_action_", ""),
|
|
485
|
+
browser=self,
|
|
486
|
+
)
|
|
487
|
+
remaining_completion = (
|
|
488
|
+
completion_deadline - datetime.now(timezone.utc)
|
|
489
|
+
).total_seconds()
|
|
490
|
+
|
|
491
|
+
except asyncio.TimeoutError:
|
|
492
|
+
phase = "completion" if accepted else "acceptance"
|
|
493
|
+
await self._expire_captcha_event(child_event_id)
|
|
494
|
+
raise CaptchaTimeoutError(phase) from None
|
|
495
|
+
finally:
|
|
496
|
+
self.chat._action_queues.pop(child_event_id, None)
|
|
497
|
+
|
|
498
|
+
async def _finish_captcha(
|
|
499
|
+
self,
|
|
500
|
+
child_event_id: int,
|
|
501
|
+
data: dict[str, Any],
|
|
502
|
+
auto_accept: bool,
|
|
503
|
+
*,
|
|
504
|
+
solved: bool,
|
|
505
|
+
) -> CaptchaResult:
|
|
506
|
+
result = CaptchaResult(
|
|
507
|
+
solved=solved,
|
|
508
|
+
child_event_id=child_event_id,
|
|
509
|
+
proof_message_id=data.get("proof_message_id"),
|
|
510
|
+
correction_id=data.get("correction_id"),
|
|
511
|
+
browser=self,
|
|
512
|
+
)
|
|
513
|
+
if auto_accept and solved and result.correction_id:
|
|
514
|
+
await asyncio.sleep(2)
|
|
515
|
+
await result.accept_work()
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
async def _create_captcha_event(
|
|
519
|
+
self, acceptance_timeout: float, completion_timeout: float,
|
|
520
|
+
) -> int:
|
|
521
|
+
body = {
|
|
522
|
+
"acceptance_deadline_at": int(acceptance_timeout),
|
|
523
|
+
"completion_deadline_at": int(completion_timeout),
|
|
524
|
+
}
|
|
525
|
+
async with httpx.AsyncClient() as http:
|
|
526
|
+
resp = await http.post(
|
|
527
|
+
f"{self._client.api_url}/api/agent/sessions/{self._match.event_id}/captcha-request",
|
|
528
|
+
headers={**self._api_headers(), "Content-Type": "application/json"},
|
|
529
|
+
json=body,
|
|
530
|
+
)
|
|
531
|
+
resp.raise_for_status()
|
|
532
|
+
result = resp.json()
|
|
533
|
+
event_id = result.get("id")
|
|
534
|
+
if not event_id:
|
|
535
|
+
raise RuntimeError("captcha request did not return an id")
|
|
536
|
+
return int(event_id)
|
|
537
|
+
|
|
538
|
+
async def _expire_captcha_event(self, child_event_id: int) -> None:
|
|
539
|
+
try:
|
|
540
|
+
async with httpx.AsyncClient() as http:
|
|
541
|
+
await http.patch(
|
|
542
|
+
f"{self._client.api_url}/api/agent/kal/event/{child_event_id}",
|
|
543
|
+
headers={**self._api_headers(), "Content-Type": "application/json"},
|
|
544
|
+
json={"status_id": 777},
|
|
545
|
+
)
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
log.warning("expire captcha event %d failed: %s", child_event_id, exc)
|
|
548
|
+
|
|
549
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
550
|
+
# Internal dispatch (called from Client._reader_loop)
|
|
551
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
async def _on_cdp_response(self, msg: dict[str, Any]) -> None:
|
|
554
|
+
cmd_id = msg.get("id")
|
|
555
|
+
if cmd_id is not None and cmd_id in self._pending_cdp:
|
|
556
|
+
fut = self._pending_cdp.pop(cmd_id)
|
|
557
|
+
if not fut.done():
|
|
558
|
+
if msg.get("ok", True):
|
|
559
|
+
fut.set_result(msg.get("result", {}))
|
|
560
|
+
else:
|
|
561
|
+
err = msg.get("error", {})
|
|
562
|
+
fut.set_exception(Exception(f"CDP error {err}"))
|
|
563
|
+
|
|
564
|
+
async def _on_cdp_event(self, msg: dict[str, Any]) -> None:
|
|
565
|
+
method = msg.get("method", "")
|
|
566
|
+
params = msg.get("params", {})
|
|
567
|
+
for cb in self._event_callbacks:
|
|
568
|
+
asyncio.create_task(cast(Coroutine, cb(method, params)))
|
|
569
|
+
|
|
570
|
+
async def _on_tab_opened(self, msg: dict[str, Any]) -> None:
|
|
571
|
+
url = msg.get("url", "")
|
|
572
|
+
for cb in self._tab_opened_callbacks:
|
|
573
|
+
asyncio.create_task(cast(Coroutine, cb(url)))
|
|
574
|
+
|
|
575
|
+
async def _on_session_ended(self, msg: dict[str, Any]) -> None:
|
|
576
|
+
reason = msg.get("reason", "completed")
|
|
577
|
+
self._ended_reason = reason
|
|
578
|
+
if reason == "provider_disconnected":
|
|
579
|
+
exc: Exception = ProviderDisconnected()
|
|
580
|
+
else:
|
|
581
|
+
exc = SessionEnded(reason)
|
|
582
|
+
for fut in self._pending_cdp.values():
|
|
583
|
+
if not fut.done():
|
|
584
|
+
fut.set_exception(exc)
|
|
585
|
+
self._pending_cdp.clear()
|
|
586
|
+
self._ended.set()
|
|
587
|
+
self._client._active_browsers.pop(self.session_id, None)
|
|
588
|
+
|
|
589
|
+
async def _on_provider_disconnected(self, msg: dict[str, Any]) -> None:
|
|
590
|
+
for cb in self._provider_disconnected_callbacks:
|
|
591
|
+
asyncio.create_task(cast(Coroutine, cb()))
|
|
592
|
+
|
|
593
|
+
async def _on_provider_reconnected(self, msg: dict[str, Any]) -> None:
|
|
594
|
+
for cb in self._provider_reconnected_callbacks:
|
|
595
|
+
asyncio.create_task(cast(Coroutine, cb()))
|
|
596
|
+
|
|
597
|
+
async def _on_user_events(self, msg: dict[str, Any]) -> None:
|
|
598
|
+
events: list[dict[str, Any]] = msg.get("events", [])
|
|
599
|
+
for cb in self._user_event_callbacks:
|
|
600
|
+
asyncio.create_task(cast(Coroutine, cb(events)))
|
|
601
|
+
|
|
602
|
+
async def _on_error(self, msg: dict[str, Any]) -> None:
|
|
603
|
+
code = msg.get("code", 0)
|
|
604
|
+
cmd_id = msg.get("id")
|
|
605
|
+
|
|
606
|
+
if code == -1013:
|
|
607
|
+
exc: Exception = RateLimitExceeded(retry_after=float(msg.get("retry_after", 1.0)))
|
|
608
|
+
if cmd_id is not None and cmd_id in self._pending_cdp:
|
|
609
|
+
fut = self._pending_cdp.pop(cmd_id)
|
|
610
|
+
if not fut.done():
|
|
611
|
+
fut.set_exception(exc)
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
if code == -1050:
|
|
615
|
+
last_err = msg.get("last_error", msg.get("message", "cdp_error"))
|
|
616
|
+
exc = CdpUnrecoverable(last_error=str(last_err))
|
|
617
|
+
if cmd_id is not None and cmd_id in self._pending_cdp:
|
|
618
|
+
fut = self._pending_cdp.pop(cmd_id)
|
|
619
|
+
if not fut.done():
|
|
620
|
+
fut.set_exception(exc)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
if code == -1011:
|
|
624
|
+
reason = "heartbeat_timeout"
|
|
625
|
+
elif code == -1012:
|
|
626
|
+
reason = "insufficient_funds"
|
|
627
|
+
elif code == -1015:
|
|
628
|
+
reason = "provider_declined"
|
|
629
|
+
elif code == -1018:
|
|
630
|
+
reason = "killed"
|
|
631
|
+
else:
|
|
632
|
+
reason = msg.get("reason") or msg.get("message") or f"error_{code}"
|
|
633
|
+
|
|
634
|
+
self._ended_reason = reason
|
|
635
|
+
terminal_exc: Exception
|
|
636
|
+
if code == -1012:
|
|
637
|
+
terminal_exc = InsufficientFunds()
|
|
638
|
+
else:
|
|
639
|
+
terminal_exc = SessionEnded(reason)
|
|
640
|
+
|
|
641
|
+
for fut in self._pending_cdp.values():
|
|
642
|
+
if not fut.done():
|
|
643
|
+
fut.set_exception(terminal_exc)
|
|
644
|
+
self._pending_cdp.clear()
|
|
645
|
+
self._ended.set()
|
|
646
|
+
self._client._active_browsers.pop(self.session_id, None)
|