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/*.dtmp
|
||||
build/
|
||||
client/dist/
|
||||
client/build/
|
||||
client/src/*.egg-info/
|
||||
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-клиента
|
||||
к Robotex ставьте не меньше `timeout` + 120 с + сумма таймаутов действий. Так же настройте reverse proxy перед сервисом.
|
||||
|
||||
## Python-клиент
|
||||
|
||||
Для Python есть готовый пакет `robotex` ([client/README.md](../client/README.md)): задачи, сессии с удержанием окна,
|
||||
ожидание результата, ошибки в виде исключений, sync и async.
|
||||
|
||||
```python
|
||||
from robotex import Robotex, Action
|
||||
|
||||
with Robotex("https://app.robotex.sokolab.xyz", api_key="rbx_...") as rx:
|
||||
result = rx.run("https://example.com", [Action.inner_text("title")]).raise_for_status()
|
||||
```
|
||||
|
||||
## Пример (Python, httpx)
|
||||
|
||||
```python
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = src
|
||||
pythonpath = src client/src
|
||||
testpaths = tests
|
||||
addopts = -m "not browser"
|
||||
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