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:
1 parent
11ba83140d
commit
cb85e5f3ab
16 files changed
+1004
-1
No files matched your search
@@ -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"}`.
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Whitespace-only changes.
Reference in new issue
Block a user