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
@@ -6,3 +6,6 @@ wheels/
|
|||||||
docs/*.bkp
|
docs/*.bkp
|
||||||
docs/*.dtmp
|
docs/*.dtmp
|
||||||
build/
|
build/
|
||||||
|
client/dist/
|
||||||
|
client/build/
|
||||||
|
client/src/*.egg-info/
|
||||||
@@ -38,6 +38,7 @@ help:
|
|||||||
@echo "make stats [days=7] - статистика: запросы, их время, время работы браузера по клиентам"
|
@echo "make stats [days=7] - статистика: запросы, их время, время работы браузера по клиентам"
|
||||||
@echo "make sync-dev - установить зависимости для тестов (requirements-dev.txt)"
|
@echo "make sync-dev - установить зависимости для тестов (requirements-dev.txt)"
|
||||||
@echo "make test - юнит-тесты без браузера"
|
@echo "make test - юнит-тесты без браузера"
|
||||||
|
@echo "make client-build - собрать wheel клиента robotex в client/dist/"
|
||||||
@echo "make test-browser [ver=latest] - собрать образ и прогнать сквозные тесты с Chrome в Docker (нужна сеть)"
|
@echo "make test-browser [ver=latest] - собрать образ и прогнать сквозные тесты с Chrome в Docker (нужна сеть)"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
@@ -109,6 +110,11 @@ sync-dev: venv
|
|||||||
test:
|
test:
|
||||||
$(PYTHON) -m pytest
|
$(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
|
test-browser: build
|
||||||
docker run --rm --shm-size=2g \
|
docker run --rm --shm-size=2g \
|
||||||
-v $(CURDIR)/tests:/app/tests:ro -v $(CURDIR)/pytest.ini:/app/pytest.ini:ro \
|
-v $(CURDIR)/tests:/app/tests:ro -v $(CURDIR)/pytest.ini:/app/pytest.ini:ro \
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ src/
|
|||||||
known_providers/ # cloudflare.py, recaptcha.py
|
known_providers/ # cloudflare.py, recaptcha.py
|
||||||
humanize.py # human-like mouse moves and typing
|
humanize.py # human-like mouse moves and typing
|
||||||
captcha/ # 2captcha solvers: Turnstile, reCAPTCHA, hCaptcha
|
captcha/ # 2captcha solvers: Turnstile, reCAPTCHA, hCaptcha
|
||||||
|
client/ # Python client package robotex (pip install, make client-build)
|
||||||
docs/ # API, deployment, anti-bot notes
|
docs/ # API, deployment, anti-bot notes
|
||||||
tests/
|
tests/
|
||||||
unit/ # pytest without a browser: make test
|
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,
|
[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).
|
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
|
## Roadmap
|
||||||
|
|
||||||
Not done yet:
|
Not done yet:
|
||||||
|
|||||||
@@ -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.
@@ -90,6 +90,18 @@ UA в ответе может измениться, когда на сервер
|
|||||||
Прохождение антибота может занять до 2 минут, плюс навигация (`timeout`) и действия. Таймаут вашего HTTP-клиента
|
Прохождение антибота может занять до 2 минут, плюс навигация (`timeout`) и действия. Таймаут вашего HTTP-клиента
|
||||||
к Robotex ставьте не меньше `timeout` + 120 с + сумма таймаутов действий. Так же настройте reverse proxy перед сервисом.
|
к 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, httpx)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = src
|
pythonpath = src client/src
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
addopts = -m "not browser"
|
addopts = -m "not browser"
|
||||||
markers =
|
markers =
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in new issue
Block a user