diff --git a/.gitignore b/.gitignore index 00687ad..030a8c6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ wheels/ docs/*.bkp docs/*.dtmp build/ +client/dist/ +client/build/ +client/src/*.egg-info/ diff --git a/Makefile b/Makefile index a587fc4..c460b0a 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/README.md b/README.md index d26ed5b..82d3112 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..6b161a3 --- /dev/null +++ b/client/README.md @@ -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"}`. diff --git a/client/pyproject.toml b/client/pyproject.toml new file mode 100644 index 0000000..ad16ee4 --- /dev/null +++ b/client/pyproject.toml @@ -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"] diff --git a/client/src/robotex/__init__.py b/client/src/robotex/__init__.py new file mode 100644 index 0000000..bdfafd8 --- /dev/null +++ b/client/src/robotex/__init__.py @@ -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", +] diff --git a/client/src/robotex/_core.py b/client/src/robotex/_core.py new file mode 100644 index 0000000..e1de67e --- /dev/null +++ b/client/src/robotex/_core.py @@ -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 diff --git a/client/src/robotex/actions.py b/client/src/robotex/actions.py new file mode 100644 index 0000000..cb4b071 --- /dev/null +++ b/client/src/robotex/actions.py @@ -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") diff --git a/client/src/robotex/aio.py b/client/src/robotex/aio.py new file mode 100644 index 0000000..5e66939 --- /dev/null +++ b/client/src/robotex/aio.py @@ -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 diff --git a/client/src/robotex/client.py b/client/src/robotex/client.py new file mode 100644 index 0000000..e4ab560 --- /dev/null +++ b/client/src/robotex/client.py @@ -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 diff --git a/client/src/robotex/errors.py b/client/src/robotex/errors.py new file mode 100644 index 0000000..2ae8d30 --- /dev/null +++ b/client/src/robotex/errors.py @@ -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 diff --git a/client/src/robotex/models.py b/client/src/robotex/models.py new file mode 100644 index 0000000..1588a32 --- /dev/null +++ b/client/src/robotex/models.py @@ -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, + ) diff --git a/client/src/robotex/py.typed b/client/src/robotex/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/Usage.md b/docs/Usage.md index a2f4347..8405405 100644 --- a/docs/Usage.md +++ b/docs/Usage.md @@ -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 diff --git a/pytest.ini b/pytest.ini index 61000d7..3c692e7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -pythonpath = src +pythonpath = src client/src testpaths = tests addopts = -m "not browser" markers = diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..19d4a02 --- /dev/null +++ b/tests/unit/test_client.py @@ -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 == "page" 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())