Python-клиент robotex: sync/async, сессии, задачи v2, ошибки

Пакет в client/ (зависимость — httpx), установка через pip из git или
wheel (make client-build). Тесты против api и воркера из harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 22:18:40 +04:00
1 parent 11ba83140d
commit cb85e5f3ab
16 files changed
+1004 -1

No files matched your search

+3
View File
@@ -6,3 +6,6 @@ wheels/
docs/*.bkp
docs/*.dtmp
build/
client/dist/
client/build/
client/src/*.egg-info/
+6
View File
@@ -38,6 +38,7 @@ help:
@echo "make stats [days=7] - статистика: запросы, их время, время работы браузера по клиентам"
@echo "make sync-dev - установить зависимости для тестов (requirements-dev.txt)"
@echo "make test - юнит-тесты без браузера"
@echo "make client-build - собрать wheel клиента robotex в client/dist/"
@echo "make test-browser [ver=latest] - собрать образ и прогнать сквозные тесты с Chrome в Docker (нужна сеть)"
venv:
@@ -109,6 +110,11 @@ sync-dev: venv
test:
$(PYTHON) -m pytest
client-build: venv
rm -rf client/dist client/build
$(PIP) wheel ./client -w client/dist --no-deps
@echo "Готово: client/dist/ — pip install client/dist/robotex-*.whl"
test-browser: build
docker run --rm --shm-size=2g \
-v $(CURDIR)/tests:/app/tests:ro -v $(CURDIR)/pytest.ini:/app/pytest.ini:ro \
+7
View File
@@ -83,6 +83,7 @@ src/
known_providers/ # cloudflare.py, recaptcha.py
humanize.py # human-like mouse moves and typing
captcha/ # 2captcha solvers: Turnstile, reCAPTCHA, hCaptcha
client/ # Python client package robotex (pip install, make client-build)
docs/ # API, deployment, anti-bot notes
tests/
unit/ # pytest without a browser: make test
@@ -148,6 +149,12 @@ Full reference — fields, action types, errors, sessions, v2:
[docs/API.md](docs/API.md). How to use the API from your client (same UA and proxy as the browser, sessions,
retries) — [docs/Usage.md](docs/Usage.md).
Python client: [client/README.md](client/README.md).
```bash
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.1.0#subdirectory=client"
```
## Roadmap
Not done yet:
+104
View File
@@ -0,0 +1,104 @@
# robotex — Python-клиент Robotex API
Обвязка над HTTP API Robotex (docs/API.md): задачи v2 через очередь, синхронный `/v1/solve`, сессии с удержанием окна
браузера. Синхронный `Robotex` и асинхронный `AsyncRobotex` с одинаковым интерфейсом. Зависимость одна — `httpx`.
## Установка
```bash
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.1.0#subdirectory=client"
```
или из wheel (`make client-build` в репозитории кладёт его в `client/dist/`):
```bash
pip install robotex-0.1.0-py3-none-any.whl
```
## Быстрый старт
```python
from robotex import Robotex, Action
with Robotex("https://robotex.example.com", api_key="rbx_...") as rx:
result = rx.run(
"https://example.com",
[Action.wait_for("h1"), Action.inner_text("h1")],
html=True,
).raise_for_status()
print(result.results[0], result.user_agent, result.cookie("cf_clearance"))
```
`base_url` и `api_key` можно не передавать — тогда берутся из `ROBOTEX_URL` и `ROBOTEX_API_KEY`.
Остальные параметры запроса передаются именованными аргументами как есть: `proxy`, `cookies`, `html`, `screenshot`,
`timeout`, `captcha`, `captcha_type`, `disable_resources`, `blocked_domains`, `retries`, `wait`, `google_search` и т. д.
## Методы
| Метод | Что делает |
|---|---|
| `run(url, actions, **opts)` | задача v2: поставить в очередь и дождаться результата (`wait=` секунд, по умолчанию 600) |
| `submit(url, actions, **opts)` | только поставить; вернёт `TaskRef` с `.status()` и `.wait()` |
| `solve(url, actions, **opts)` | синхронный `/v1/solve`; `NoSlotsError`, если свободных браузеров нет |
| `session(session_id=None, idle_timeout=5)` | сессия: один профиль и окно, которое держится `idle_timeout` минут |
| `close_session(session_id)` | закрыть окно сессии и освободить слот |
| `health()` | слоты и воркеры |
Вызовы без сессии каждый раз получают новый профиль браузера.
## Сессии и удержание окна
```python
with rx.session(idle_timeout=10) as s:
s.run("https://site/login", [
Action.typing("#login", "me"),
Action.typing("#password", "secret"),
Action.submit("button[type=submit]"),
]).raise_for_status()
# без url — на текущей странице того же окна, без повторного запуска Chrome и антибота
balance = s.run(actions=[Action.inner_text(".balance")]).results[0]
saved = s.id
# на выходе из with окно закрывается (слот освобождается через секунду-две), профиль остаётся
again = rx.session(saved).run("https://site/account") # продолжить сессию позже
```
## Асинхронно
```python
import asyncio
from robotex import AsyncRobotex, Action
async def main():
async with AsyncRobotex() as rx:
results = await asyncio.gather(*(rx.run(url, [Action.inner_text("h1")]) for url in urls))
async with rx.session(idle_timeout=5) as s:
await s.run("https://example.com")
```
## Результат и ошибки
`Result`: `status` (`ok`/`err`), `code`, `error`, `user_agent`, `cookies`, `actions` (`ActionResult`), `results`
(тексты действий по порядку), `html`, `screenshot` (base64, `screenshot_bytes()`), `duration_ms`, `session_id`.
Ошибка выполнения (`challenge_failed`, `action_failed`, `request_failed`) — это результат со `status="err"`, исключения
нет; `raise_for_status()` превращает её в `ResultError` (в `e.result` всё собранное: cookies, html, скриншот).
Исключения (все от `RobotexError`):
| Исключение | Когда |
|---|---|
| `AuthError` | 401 — ключ не передан, неверен или отозван |
| `InvalidRequest` | 422 — неверное действие, нет `url` без открытого окна и т. п. |
| `NoSlotsError` | 503 у `solve()`; `retry_after` — через сколько секунд повторить |
| `NotFound` | 404 — задача истекла или из другой сессии |
| `TaskFailed` | задача `failed`/`cancelled` (например, упал воркер) |
| `TaskTimeout` | `wait` истёк; задача продолжает выполняться, `TaskRef.wait()` можно повторить |
| `ResultError` | `raise_for_status()` на результате со `status="err"` |
## Действия
`Action.fill`, `typing`, `click`, `submit`, `mouse_move`, `delay(seconds)`, `wait_for(selector, timeout=None)`,
`inner_html`, `inner_text`, `captcha(selector, kind)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
API: `{"type": "click", "selector": "#a"}`.
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "robotex"
version = "0.1.0"
description = "Python-клиент Robotex API: браузер с обходом антиботов, задачи v2 и окна сессий"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
[project.urls]
Source = "https://git.sokolab.xyz/s0k0l/robotex"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
robotex = ["py.typed"]
+30
View File
@@ -0,0 +1,30 @@
"""Клиент Robotex API.
from robotex import Robotex, Action
with Robotex("https://robotex.example.com", api_key="rbx_...") as rx:
result = rx.run("https://example.com", [Action.inner_text("h1")]).raise_for_status()
print(result.results[0])
"""
from robotex.actions import Action
from robotex.aio import AsyncRobotex, AsyncSession, AsyncTaskRef
from robotex.client import Robotex, Session, TaskRef
from robotex.errors import (
AuthError,
InvalidRequest,
NoSlotsError,
NotFound,
ResultError,
RobotexError,
TaskFailed,
TaskTimeout,
)
from robotex.models import ActionResult, Result, Task
__version__ = "0.1.0"
__all__ = [
"Action", "ActionResult", "AsyncRobotex", "AsyncSession", "AsyncTaskRef", "AuthError", "InvalidRequest",
"NoSlotsError", "NotFound", "Result", "ResultError", "Robotex", "RobotexError", "Session", "Task", "TaskFailed",
"TaskRef", "TaskTimeout",
]
+94
View File
@@ -0,0 +1,94 @@
"""Общее для синхронного и асинхронного клиента: тело запроса, cookie сессии, разбор ответов."""
import os
from http.cookiejar import CookieJar, DefaultCookiePolicy
from http.cookies import SimpleCookie
from typing import Any, Iterable
import httpx
from robotex.actions import Action
from robotex.errors import AuthError, InvalidRequest, NoSlotsError, NotFound, RobotexError, TaskFailed
from robotex.models import Result, Task
DEFAULT_TIMEOUT = 30.0
DEFAULT_POLL_INTERVAL = 1.0
DEFAULT_WAIT = 600.0
SESSION_COOKIE = "session_id"
ActionLike = Action | dict[str, Any]
def settings(base_url: str | None, api_key: str | None) -> tuple[str, str]:
base_url = base_url or os.getenv("ROBOTEX_URL")
api_key = api_key or os.getenv("ROBOTEX_API_KEY")
if not base_url:
raise ValueError("base_url is required (or set ROBOTEX_URL)")
if not api_key:
raise ValueError("api_key is required (or set ROBOTEX_API_KEY)")
return base_url.rstrip("/"), api_key
def client_kwargs(base_url: str, api_key: str, timeout: float, transport) -> dict[str, Any]:
# cookie session_id клиент передаёт сам: общая банка cookies смешала бы сессии разных вызовов
return {
"base_url": base_url,
"headers": {"X-API-Key": api_key, "User-Agent": "robotex-python"},
"timeout": timeout,
"transport": transport,
"cookies": CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
}
def session_headers(session_id: str | None) -> dict[str, str]:
return {"Cookie": f"{SESSION_COOKIE}={session_id}"} if session_id else {}
def body(url: str | None, actions: Iterable[ActionLike], options: dict[str, Any]) -> dict[str, Any]:
data = {k: v for k, v in options.items() if v is not None}
data["actions"] = [a.to_dict() if isinstance(a, Action) else dict(a) for a in actions]
if url is not None:
data["url"] = url
return data
def response_session(response: httpx.Response, fallback: str | None) -> str | None:
for header in response.headers.get_list("set-cookie"):
morsel = SimpleCookie(header).get(SESSION_COOKIE)
if morsel and morsel.value:
return morsel.value
return fallback
def _detail(response: httpx.Response) -> str:
try:
detail = response.json().get("detail", response.text)
except ValueError:
return response.text
if isinstance(detail, list): # ошибки проверки FastAPI
return "; ".join(f"{'.'.join(map(str, e.get('loc', [])[1:]))}: {e.get('msg')}" for e in detail)
return str(detail)
def check(response: httpx.Response) -> httpx.Response:
code = response.status_code
if code < 400:
return response
if code == 401:
raise AuthError(_detail(response), code)
if code == 404:
raise NotFound(_detail(response), code)
if code == 422:
raise InvalidRequest(_detail(response), code)
if code == 503:
retry_after = response.headers.get("retry-after")
raise NoSlotsError("no free browser slots", float(retry_after) if retry_after else None)
raise RobotexError(f"HTTP {code}: {response.text[:500]}", code)
def finished_result(task: Task, session_id: str | None) -> Result | None:
"""Result готовой задачи, None — ещё выполняется; TaskFailed — не выполнена."""
if task.status == "done":
return task.result or Result("err", "request_failed", "empty result", session_id=session_id)
if task.status in ("failed", "cancelled"):
raise TaskFailed(f"task {task.task_id} {task.status}: {task.error}", task.task_id, task.status)
return None
+69
View File
@@ -0,0 +1,69 @@
"""Конструкторы действий — те же типы, что в docs/API.md. Вместо Action можно передавать dict."""
from dataclasses import dataclass
from typing import Any
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
@dataclass(frozen=True)
class Action:
type: str
selector: str | None = None
value: str | None = None
timeout: int | None = None
def to_dict(self) -> dict[str, Any]:
return {k: v for k, v in vars(self).items() if v is not None}
@classmethod
def fill(cls, selector: str, value: str) -> "Action":
"""Вставить значение в поле мгновенно."""
return cls("fill", selector, value)
@classmethod
def typing(cls, selector: str, value: str) -> "Action":
"""Набрать значение по-человечески: движение мыши, клик, нажатия с паузами."""
return cls("typing", selector, value)
@classmethod
def click(cls, selector: str) -> "Action":
return cls("click", selector)
@classmethod
def submit(cls, selector: str) -> "Action":
"""Клик и ожидание навигации."""
return cls("submit", selector)
@classmethod
def mouse_move(cls, selector: str) -> "Action":
return cls("mouse_move", selector)
@classmethod
def delay(cls, seconds: int) -> "Action":
"""Пауза 0–600 секунд."""
return cls("delay", value=str(int(seconds)))
@classmethod
def wait_for(cls, selector: str, timeout: int | None = None) -> "Action":
"""Дождаться элемента; timeout 1–7200 секунд, по умолчанию 30."""
return cls("wait_for", selector, timeout=timeout)
@classmethod
def inner_html(cls, selector: str) -> "Action":
return cls("inner_html", selector)
@classmethod
def inner_text(cls, selector: str) -> "Action":
return cls("inner_text", selector)
@classmethod
def captcha(cls, selector: str, kind: str) -> "Action":
"""Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ)."""
if kind not in CAPTCHA_TYPES:
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)}")
return cls("captcha", selector, kind)
@classmethod
def pass_challenge(cls) -> "Action":
"""Пройти антибот на текущей странице (например, после submit)."""
return cls("pass_challenge")
+174
View File
@@ -0,0 +1,174 @@
import asyncio
import time
from typing import Any, Iterable
import httpx
from robotex import _core
from robotex._core import ActionLike
from robotex.errors import TaskTimeout
from robotex.models import Result, Task
class AsyncRobotex:
"""Асинхронный клиент, тот же интерфейс, что у Robotex. base_url и api_key по умолчанию берутся из ROBOTEX_URL и ROBOTEX_API_KEY.
Каждый вызов без сессии получает новый профиль браузера; чтобы сохранять cookies и держать окно
между задачами, используйте session().
Параметры запроса (proxy, cookies, html, screenshot, captcha, wait, …) передаются как именованные
аргументы, см. docs/API.md.
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
*,
timeout: float = _core.DEFAULT_TIMEOUT,
poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
transport: httpx.AsyncBaseTransport | None = None,
):
base_url, api_key = _core.settings(base_url, api_key)
self.poll_interval = poll_interval
self._http = httpx.AsyncClient(**_core.client_kwargs(base_url, api_key, timeout, transport))
async def __aenter__(self) -> "AsyncRobotex":
return self
async def __aexit__(self, *exc) -> None:
await self.close()
async def close(self) -> None:
await self._http.aclose()
async def health(self) -> dict[str, Any]:
"""Состояние сервиса: browser_slots, free_slots, воркеры. Ключ не нужен."""
return _core.check(await self._http.get("/health")).json()
async def solve(
self, url: str, actions: Iterable[ActionLike] = (), *, session_id: str | None = None, **options: Any,
) -> Result:
"""Синхронный /v1/solve: ответ, когда задача выполнена. NoSlotsError, если браузеров нет."""
response = _core.check(await self._http.post(
"/v1/solve", json=_core.body(url, actions, options), headers=_core.session_headers(session_id),
timeout=httpx.Timeout(self._http.timeout.connect, read=None),
))
return Result.from_dict(response.json(), _core.response_session(response, session_id))
async def submit(
self,
url: str | None = None,
actions: Iterable[ActionLike] = (),
*,
session_id: str | None = None,
idle_timeout: int = 0,
**options: Any,
) -> "AsyncTaskRef":
"""Поставить задачу v2 в очередь. idle_timeout — минуты (0–120), сколько держать окно после задачи.
Без url задача выполняется на текущей странице открытого окна сессии."""
data = _core.body(url, actions, options)
data["browser_idle_timeout"] = idle_timeout
response = _core.check(await self._http.post("/v2/tasks", json=data, headers=_core.session_headers(session_id)))
return AsyncTaskRef(self, response.json()["task_id"], _core.response_session(response, session_id))
async def task(self, task_id: str, session_id: str) -> Task:
"""Состояние задачи. Видна только из своей сессии."""
response = _core.check(await self._http.get(f"/v2/tasks/{task_id}", headers=_core.session_headers(session_id)))
return Task.from_dict(response.json(), session_id)
async def wait(
self, task_id: str, session_id: str, timeout: float | None = _core.DEFAULT_WAIT,
poll_interval: float | None = None,
) -> Result:
"""Дождаться результата задачи. TaskTimeout — не успела (выполняется дальше), TaskFailed — не выполнена."""
deadline = None if timeout is None else time.monotonic() + timeout
interval = poll_interval or self.poll_interval
while True:
result = _core.finished_result(await self.task(task_id, session_id), session_id)
if result is not None:
return result
if deadline is not None and time.monotonic() >= deadline:
raise TaskTimeout(f"task {task_id} is not finished after {timeout}s", task_id)
await asyncio.sleep(interval)
async def run(
self,
url: str | None = None,
actions: Iterable[ActionLike] = (),
*,
session_id: str | None = None,
idle_timeout: int = 0,
wait: float | None = _core.DEFAULT_WAIT,
**options: Any,
) -> Result:
"""submit() и wait(): задача через очередь, без 503 при занятых браузерах."""
ref = await self.submit(url, actions, session_id=session_id, idle_timeout=idle_timeout, **options)
return await ref.wait(wait)
async def close_session(self, session_id: str) -> bool:
"""Закрыть окно сессии и освободить слот. False — окна не было."""
response = await self._http.delete("/v2/sessions/current", headers=_core.session_headers(session_id))
return _core.check(response).json()["closed"]
def session(self, session_id: str | None = None, idle_timeout: int = 5) -> "AsyncSession":
"""Сессия: один профиль браузера (cookies, localStorage, отпечаток) и окно, которое держится
idle_timeout минут между задачами. session_id — продолжить сохранённую сессию."""
return AsyncSession(self, session_id, idle_timeout)
class AsyncTaskRef:
"""Поставленная задача v2."""
def __init__(self, client: AsyncRobotex, task_id: str, session_id: str):
self.client, self.task_id, self.session_id = client, task_id, session_id
def __repr__(self) -> str:
return f"AsyncTaskRef({self.task_id!r}, session_id={self.session_id!r})"
async def status(self) -> Task:
return await self.client.task(self.task_id, self.session_id)
async def wait(self, timeout: float | None = _core.DEFAULT_WAIT, poll_interval: float | None = None) -> Result:
return await self.client.wait(self.task_id, self.session_id, timeout, poll_interval)
class AsyncSession:
"""Задачи одной сессии. В with-блоке окно закрывается на выходе.
async with rx.session(idle_timeout=10) as s:
await s.run("https://site/login", [Action.typing("#user", "me"), Action.submit("button")])
text = (await s.run(actions=[Action.inner_text(".balance")])).results[0] # та же страница
"""
def __init__(self, client: AsyncRobotex, session_id: str | None = None, idle_timeout: int = 5):
self.client, self.id, self.idle_timeout = client, session_id, idle_timeout
async def __aenter__(self) -> "AsyncSession":
return self
async def __aexit__(self, *exc) -> None:
await self.close()
def __repr__(self) -> str:
return f"AsyncSession({self.id!r}, idle_timeout={self.idle_timeout})"
async def submit(self, url: str | None = None, actions: Iterable[ActionLike] = (), **options: Any) -> AsyncTaskRef:
options.setdefault("idle_timeout", self.idle_timeout)
ref = await self.client.submit(url, actions, session_id=self.id, **options)
self.id = ref.session_id
return ref
async def run(
self, url: str | None = None, actions: Iterable[ActionLike] = (), *,
wait: float | None = _core.DEFAULT_WAIT, **options: Any,
) -> Result:
return await (await self.submit(url, actions, **options)).wait(wait)
async def solve(self, url: str, actions: Iterable[ActionLike] = (), **options: Any) -> Result:
result = await self.client.solve(url, actions, session_id=self.id, **options)
self.id = result.session_id
return result
async def close(self) -> bool:
"""Закрыть окно браузера; профиль остаётся, сессию можно продолжить."""
return await self.client.close_session(self.id) if self.id else False
+173
View File
@@ -0,0 +1,173 @@
import time
from typing import Any, Iterable
import httpx
from robotex import _core
from robotex._core import ActionLike
from robotex.errors import TaskTimeout
from robotex.models import Result, Task
class Robotex:
"""Синхронный клиент. base_url и api_key по умолчанию берутся из ROBOTEX_URL и ROBOTEX_API_KEY.
Каждый вызов без сессии получает новый профиль браузера; чтобы сохранять cookies и держать окно
между задачами, используйте session().
Параметры запроса (proxy, cookies, html, screenshot, captcha, wait, …) передаются как именованные
аргументы, см. docs/API.md.
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
*,
timeout: float = _core.DEFAULT_TIMEOUT,
poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
transport: httpx.BaseTransport | None = None,
):
base_url, api_key = _core.settings(base_url, api_key)
self.poll_interval = poll_interval
self._http = httpx.Client(**_core.client_kwargs(base_url, api_key, timeout, transport))
def __enter__(self) -> "Robotex":
return self
def __exit__(self, *exc) -> None:
self.close()
def close(self) -> None:
self._http.close()
def health(self) -> dict[str, Any]:
"""Состояние сервиса: browser_slots, free_slots, воркеры. Ключ не нужен."""
return _core.check(self._http.get("/health")).json()
def solve(
self, url: str, actions: Iterable[ActionLike] = (), *, session_id: str | None = None, **options: Any,
) -> Result:
"""Синхронный /v1/solve: ответ, когда задача выполнена. NoSlotsError, если браузеров нет."""
response = _core.check(self._http.post(
"/v1/solve", json=_core.body(url, actions, options), headers=_core.session_headers(session_id),
timeout=httpx.Timeout(self._http.timeout.connect, read=None),
))
return Result.from_dict(response.json(), _core.response_session(response, session_id))
def submit(
self,
url: str | None = None,
actions: Iterable[ActionLike] = (),
*,
session_id: str | None = None,
idle_timeout: int = 0,
**options: Any,
) -> "TaskRef":
"""Поставить задачу v2 в очередь. idle_timeout — минуты (0–120), сколько держать окно после задачи.
Без url задача выполняется на текущей странице открытого окна сессии."""
data = _core.body(url, actions, options)
data["browser_idle_timeout"] = idle_timeout
response = _core.check(self._http.post("/v2/tasks", json=data, headers=_core.session_headers(session_id)))
return TaskRef(self, response.json()["task_id"], _core.response_session(response, session_id))
def task(self, task_id: str, session_id: str) -> Task:
"""Состояние задачи. Видна только из своей сессии."""
response = _core.check(self._http.get(f"/v2/tasks/{task_id}", headers=_core.session_headers(session_id)))
return Task.from_dict(response.json(), session_id)
def wait(
self, task_id: str, session_id: str, timeout: float | None = _core.DEFAULT_WAIT,
poll_interval: float | None = None,
) -> Result:
"""Дождаться результата задачи. TaskTimeout — не успела (выполняется дальше), TaskFailed — не выполнена."""
deadline = None if timeout is None else time.monotonic() + timeout
interval = poll_interval or self.poll_interval
while True:
result = _core.finished_result(self.task(task_id, session_id), session_id)
if result is not None:
return result
if deadline is not None and time.monotonic() >= deadline:
raise TaskTimeout(f"task {task_id} is not finished after {timeout}s", task_id)
time.sleep(interval)
def run(
self,
url: str | None = None,
actions: Iterable[ActionLike] = (),
*,
session_id: str | None = None,
idle_timeout: int = 0,
wait: float | None = _core.DEFAULT_WAIT,
**options: Any,
) -> Result:
"""submit() и wait(): задача через очередь, без 503 при занятых браузерах."""
ref = self.submit(url, actions, session_id=session_id, idle_timeout=idle_timeout, **options)
return ref.wait(wait)
def close_session(self, session_id: str) -> bool:
"""Закрыть окно сессии и освободить слот. False — окна не было."""
response = self._http.delete("/v2/sessions/current", headers=_core.session_headers(session_id))
return _core.check(response).json()["closed"]
def session(self, session_id: str | None = None, idle_timeout: int = 5) -> "Session":
"""Сессия: один профиль браузера (cookies, localStorage, отпечаток) и окно, которое держится
idle_timeout минут между задачами. session_id — продолжить сохранённую сессию."""
return Session(self, session_id, idle_timeout)
class TaskRef:
"""Поставленная задача v2."""
def __init__(self, client: Robotex, task_id: str, session_id: str):
self.client, self.task_id, self.session_id = client, task_id, session_id
def __repr__(self) -> str:
return f"TaskRef({self.task_id!r}, session_id={self.session_id!r})"
def status(self) -> Task:
return self.client.task(self.task_id, self.session_id)
def wait(self, timeout: float | None = _core.DEFAULT_WAIT, poll_interval: float | None = None) -> Result:
return self.client.wait(self.task_id, self.session_id, timeout, poll_interval)
class Session:
"""Задачи одной сессии. В with-блоке окно закрывается на выходе.
with rx.session(idle_timeout=10) as s:
s.run("https://site/login", [Action.typing("#user", "me"), Action.submit("button")])
text = s.run(actions=[Action.inner_text(".balance")]).results[0] # та же страница
"""
def __init__(self, client: Robotex, session_id: str | None = None, idle_timeout: int = 5):
self.client, self.id, self.idle_timeout = client, session_id, idle_timeout
def __enter__(self) -> "Session":
return self
def __exit__(self, *exc) -> None:
self.close()
def __repr__(self) -> str:
return f"Session({self.id!r}, idle_timeout={self.idle_timeout})"
def submit(self, url: str | None = None, actions: Iterable[ActionLike] = (), **options: Any) -> TaskRef:
options.setdefault("idle_timeout", self.idle_timeout)
ref = self.client.submit(url, actions, session_id=self.id, **options)
self.id = ref.session_id
return ref
def run(
self, url: str | None = None, actions: Iterable[ActionLike] = (), *,
wait: float | None = _core.DEFAULT_WAIT, **options: Any,
) -> Result:
return self.submit(url, actions, **options).wait(wait)
def solve(self, url: str, actions: Iterable[ActionLike] = (), **options: Any) -> Result:
result = self.client.solve(url, actions, session_id=self.id, **options)
self.id = result.session_id
return result
def close(self) -> bool:
"""Закрыть окно браузера; профиль остаётся, сессию можно продолжить."""
return self.client.close_session(self.id) if self.id else False
+59
View File
@@ -0,0 +1,59 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from robotex.models import Result
class RobotexError(Exception):
"""Базовая ошибка клиента. status_code — HTTP-код ответа, если ошибка пришла от API."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class AuthError(RobotexError):
"""401: ключ X-API-Key не передан, неверен или отозван."""
class InvalidRequest(RobotexError):
"""422: запрос не прошёл проверку (неизвестное действие, нет url без открытого окна и т. п.)."""
class NotFound(RobotexError):
"""404: задачи нет, она истекла или принадлежит другой сессии."""
class NoSlotsError(RobotexError):
"""503 у /v1/solve: свободных браузеров нет. retry_after — через сколько секунд повторить."""
def __init__(self, message: str, retry_after: float | None = None):
super().__init__(message, 503)
self.retry_after = retry_after
class TaskFailed(RobotexError):
"""Задачу не выполнили (failed или cancelled), например упал воркер."""
def __init__(self, message: str, task_id: str, status: str):
super().__init__(message)
self.task_id = task_id
self.status = status
class TaskTimeout(RobotexError, TimeoutError):
"""Задача не завершилась за отведённое ожидание. Она продолжает выполняться — результат можно забрать позже."""
def __init__(self, message: str, task_id: str):
super().__init__(message)
self.task_id = task_id
class ResultError(RobotexError):
"""Задача выполнена, но со status err: code — challenge_failed, action_failed, request_failed.
В result — всё, что успели собрать (cookies, html, screenshot, действия до упавшего)."""
def __init__(self, result: "Result"):
super().__init__(f"{result.code}: {result.error}")
self.code = result.code
self.result = result
+89
View File
@@ -0,0 +1,89 @@
import base64
from dataclasses import dataclass, field
from typing import Any
from robotex.errors import ResultError
@dataclass
class ActionResult:
type: str
ok: bool
result: str | None = None
duration_ms: int = 0
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ActionResult":
return cls(data["type"], data["ok"], data.get("result"), data.get("duration_ms", 0))
@dataclass
class Result:
"""Результат задачи (как ответ /v1/solve). status — ok или err, при err причина в code и error."""
status: str
code: str | None = None
error: str | None = None
user_agent: str | None = None
cookies: list[dict[str, Any]] = field(default_factory=list)
actions: list[ActionResult] = field(default_factory=list)
html: str | None = None
screenshot: str | None = None
duration_ms: int | None = None
session_id: str | None = None
@property
def ok(self) -> bool:
return self.status == "ok"
@property
def results(self) -> list[str | None]:
"""Результаты действий по порядку (текст для inner_text/inner_html, None для остальных)."""
return [a.result for a in self.actions]
def screenshot_bytes(self) -> bytes | None:
return base64.b64decode(self.screenshot) if self.screenshot else None
def cookie(self, name: str) -> str | None:
return next((c.get("value") for c in self.cookies if c.get("name") == name), None)
def raise_for_status(self) -> "Result":
"""ResultError, если status err; иначе сам результат — удобно в цепочке."""
if not self.ok:
raise ResultError(self)
return self
@classmethod
def from_dict(cls, data: dict[str, Any], session_id: str | None = None) -> "Result":
return cls(
status=data["status"],
code=data.get("code"),
error=data.get("error"),
user_agent=data.get("user_agent"),
cookies=data.get("cookies") or [],
actions=[ActionResult.from_dict(a) for a in data.get("actions") or []],
html=data.get("html"),
screenshot=data.get("screenshot"),
duration_ms=data.get("duration_ms"),
session_id=session_id,
)
@dataclass
class Task:
"""Состояние задачи v2: queued → running → done | failed | cancelled."""
task_id: str
status: str
error: str | None = None
result: Result | None = None
@property
def finished(self) -> bool:
return self.status in ("done", "failed", "cancelled")
@classmethod
def from_dict(cls, data: dict[str, Any], session_id: str | None = None) -> "Task":
result = data.get("result")
return cls(
data["task_id"], data["status"], data.get("error"),
Result.from_dict(result, session_id) if result else None,
)
View File
Whitespace-only changes.
+12
View File
@@ -90,6 +90,18 @@ UA в ответе может измениться, когда на сервер
Прохождение антибота может занять до 2 минут, плюс навигация (`timeout`) и действия. Таймаут вашего HTTP-клиента
к Robotex ставьте не меньше `timeout` + 120 с + сумма таймаутов действий. Так же настройте reverse proxy перед сервисом.
## Python-клиент
Для Python есть готовый пакет `robotex` ([client/README.md](../client/README.md)): задачи, сессии с удержанием окна,
ожидание результата, ошибки в виде исключений, sync и async.
```python
from robotex import Robotex, Action
with Robotex("https://app.robotex.sokolab.xyz", api_key="rbx_...") as rx:
result = rx.run("https://example.com", [Action.inner_text("title")]).raise_for_status()
```
## Пример (Python, httpx)
```python
+1 -1
View File
@@ -1,5 +1,5 @@
[pytest]
pythonpath = src
pythonpath = src client/src
testpaths = tests
addopts = -m "not browser"
markers =
+163
View File
@@ -0,0 +1,163 @@
"""Пакет robotex (client/) против api и воркера из tests/harness.py."""
import asyncio
import httpx
import pytest
import robotex
from api import actions
from robotex import Action
from .conftest import FakeSession, fake_execute
TEXT = [{"type": "inner_text", "ok": True, "result": "Example", "duration_ms": 3}]
class AsyncOverSync(httpx.AsyncBaseTransport):
"""Синхронный транспорт TestClient для AsyncRobotex: запрос уходит в поток."""
def __init__(self, transport):
self.transport = transport
async def handle_async_request(self, request):
await request.aread()
response = await asyncio.to_thread(self.transport.handle_request, request)
return httpx.Response(response.status_code, headers=response.headers, content=response.read())
@pytest.fixture
def rx(env):
with robotex.Robotex("http://testserver", env.api_key, transport=env.client._transport, poll_interval=0.01) as client:
yield client
def test_action_builders():
assert Action.fill("#a", "x").to_dict() == {"type": "fill", "selector": "#a", "value": "x"}
assert Action.delay(2).to_dict() == {"type": "delay", "value": "2"}
assert Action.wait_for("#a", 5).to_dict() == {"type": "wait_for", "selector": "#a", "timeout": 5}
assert Action.pass_challenge().to_dict() == {"type": "pass_challenge"}
with pytest.raises(ValueError):
Action.captcha("#c", "funcaptcha")
def test_settings_from_env(monkeypatch):
monkeypatch.delenv("ROBOTEX_URL", raising=False)
with pytest.raises(ValueError, match="ROBOTEX_URL"):
robotex.Robotex(api_key="k")
monkeypatch.setenv("ROBOTEX_URL", "http://h/")
monkeypatch.setenv("ROBOTEX_API_KEY", "k")
with robotex.Robotex() as client:
assert str(client._http.base_url) == "http://h"
def test_health(rx):
assert rx.health()["browser_slots"] == 2
def test_auth_error(env):
with robotex.Robotex("http://testserver", "rbx_wrong", transport=env.client._transport) as client:
with pytest.raises(robotex.AuthError) as e:
client.run("https://example.com")
assert e.value.status_code == 401
def test_run(rx, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
result = rx.run("https://example.com", [Action.inner_text("h1")], html=True)
assert result.ok and result.results == ["Example"]
assert result.html == "<body>page</body>" and result.user_agent == "UA/engine"
assert result.cookie("cf_clearance") == "secret-cookie"
assert result.session_id
def test_calls_without_session_are_isolated(rx):
assert rx.run("https://example.com").session_id != rx.run("https://example.com").session_id
def test_solve(rx, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
result = rx.solve("https://example.com", [Action.inner_text("h1")], screenshot=True)
assert result.ok and result.results == ["Example"]
assert result.screenshot_bytes() == b"png"
def test_no_slots(rx, env):
env.heartbeat(free_slots=0)
with pytest.raises(robotex.NoSlotsError) as e:
rx.solve("https://example.com")
assert e.value.retry_after
def test_invalid_request(rx):
with pytest.raises(robotex.InvalidRequest, match="url is required"):
rx.run(actions=[Action.click("#a")])
with pytest.raises(robotex.InvalidRequest, match="unknown action type"):
rx.run("https://example.com", [{"type": "dance"}])
def test_result_error(rx, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "click", "ok": False, "result": "no #a", "duration_ms": 1}]))
result = rx.run("https://example.com", [Action.click("#a")])
assert not result.ok and result.code == "action_failed"
with pytest.raises(robotex.ResultError) as e:
result.raise_for_status()
assert e.value.result is result
def test_request_failed(rx):
FakeSession.error = RuntimeError("net::ERR_TIMED_OUT")
result = rx.run("https://example.com")
assert result.code == "request_failed"
def test_task_cancelled(rx, env):
env.auto = False
ref = rx.submit("https://example.com")
assert env.call(env.store.cancel, ref.task_id)
with pytest.raises(robotex.TaskFailed) as e:
ref.wait()
assert e.value.status == "cancelled"
def test_submit_and_poll(rx, env):
env.auto = False
ref = rx.submit("https://example.com")
assert ref.status().status == "queued"
with pytest.raises(robotex.TaskTimeout):
ref.wait(timeout=0.05)
env.deliver_pending()
assert ref.wait().ok
with pytest.raises(robotex.NotFound):
rx.task(ref.task_id, "00000000-0000-0000-0000-000000000000")
def test_session_holds_window(rx, env):
with rx.session(idle_timeout=5) as s:
s.run("https://example.com")
s.run(actions=[Action.inner_text("h1")]) # без url — в том же окне
assert len(FakeSession.instances) == 1
assert FakeSession.instances[0].calls == [("fetch", "https://example.com"), ("run", None)]
session_id = s.id
assert FakeSession.instances[0].closed
assert not rx.close_session(session_id)
# сохранённую сессию можно продолжить: тот же профиль
resumed = rx.session(session_id, idle_timeout=0)
assert resumed.run("https://example.com").session_id == session_id
def test_async_client(env, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
async def main():
transport = AsyncOverSync(env.client._transport)
async with robotex.AsyncRobotex("http://testserver", env.api_key, transport=transport, poll_interval=0.01) as rx:
assert (await rx.health())["free_slots"] == 2
assert (await rx.solve("https://example.com")).ok
async with rx.session(idle_timeout=5) as s:
await s.run("https://example.com")
result = await s.run(actions=[Action.inner_text("h1")])
assert result.results == ["Example"] and result.session_id == s.id
assert len(FakeSession.instances) == 2 and FakeSession.instances[1].closed
asyncio.run(main())