feature: архитектура v2 — api + очередь + worker, удержание окна браузера (#78)

- Сервис api (FastAPI, без Chrome): POST /v2/tasks, GET /v2/tasks/{id} (доступ по cookie
  сессии), DELETE /v2/sessions/current; /v1/solve — прежний контракт поверх очереди.
- Сервис worker (dramatiq + RabbitMQ): слоты браузера, окна сессий между задачами
  (browser_idle_timeout 0–120 мин, простой — TTL ключа в Redis), heartbeat.
- Маршрутизация задач сессии в персональную очередь воркера с открытым окном.
- Redis: задачи, результаты (RESULT_TTL_HOURS), окна, воркеры. Прокси и ключ 2captcha
  в Redis не сохраняются.
- Движок: AsyncStealthySession.run() — действия на текущей странице без навигации.
- Два образа (robotex-api, robotex-worker), compose с rabbitmq и redis,
  make up/down/logs/smoke, релиз собирает все образы.
- Тесты: api + worker в одном процессе (fakeredis), настоящий воркер dramatiq на
  StubBroker, браузерные тесты на новой схеме, smoke для запущенного стенда.
- Документация: README, API (v2), Deployment, Usage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 19:13:52 +04:00
1 parent fe509c25c3
commit 7b511d5d38
38 files changed
+2142 -545

No files matched your search

+76 -7
View File
@@ -2,11 +2,17 @@
Как встраивать API в свой клиент (UA, прокси, сессии, повторы) — [Usage.md](Usage.md).
Основной эндпоинт: `POST /v1/solve`. Открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и,
по запросу, HTML и скриншот.
Задача открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит антибот
(Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и, по запросу,
HTML и скриншот. Задачи выполняет сервис worker из очереди; API работает в двух режимах:
`GET /health` (и `GET /`) — `{"status": "ok", "browser_slots": N, "free_slots": M}`.
* `POST /v1/solve` — синхронно: ответ приходит, когда задача выполнена;
* `POST /v2/tasks` + `GET /v2/tasks/{id}` — асинхронно, с возможностью держать окно браузера открытым между
задачами (см. [API v2](#api-v2)).
`GET /health` (и `GET /`) — `{"status": "ok", "browser_slots": N, "free_slots": M, "workers": [...]}`: слоты всех
живых воркеров; `workers` — по каждому `worker_id`, `browser_slots`, `free_slots`, `windows` (открытые окна),
`running` (выполняемые задачи).
> `X-API-Key` пока не проверяется ([#4](https://redmine.sokolab.xyz/issues/4)).
@@ -104,7 +110,7 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `code` | HTTP | Когда | Что ещё есть в ответе |
|---|---|---|---|
| `no_slots` | `503` + `Retry-After` | все браузеры заняты (`BROWSER_SLOTS`), запрос не ставится в очередь | — |
| `no_slots` | `503` + `Retry-After` | только `/v1/solve`: все слоты браузеров заняты, или воркер не взял задачу за `V1_QUEUE_WAIT` секунд (задача отменяется) | — |
| `request_failed` | `200` | ошибка до действий: навигация, прокси, неизвестный `captcha_type` | `actions: []` |
| `challenge_failed` | `200` | антибот не пройден за отведённое время, действия не выполнялись | cookies, html, screenshot |
| `action_failed` | `200` | упало действие, выполнение остановлено на нём | cookies, html, screenshot, `actions` до упавшего включительно |
@@ -139,9 +145,72 @@ canvas. Ограничение: canvas, WebGL и `hardwareConcurrency` у все
User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)).
Поле `user_agent` в запросе даёт `422`.
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
Задачи одной сессии выполняются по очереди: профиль Chrome нельзя открыть дважды. Вторая задача ждёт, пока
закончится первая.
## API v2
Асинхронный режим: задача ставится в очередь, клиент опрашивает её по ссылке. Тело задачи — как у `/v1/solve`, плюс:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `browser_idle_timeout` | int, мин | `0` | держать окно браузера открытым после задачи столько минут простоя, `0`–`120`. `0` — закрыть сразу, как в v1 |
| `url` | string | `null` | необязателен, если окно сессии открыто: тогда действия выполняются на текущей странице, без навигации |
### POST /v2/tasks
```http
POST /v2/tasks
Content-Type: application/json
Cookie: session_id=<id> # необязательно
{"url": "https://freelancehunt.com/projects", "captcha_type": "cloudflare", "actions": [], "browser_idle_timeout": 10}
```
Ответ `202` и cookie `session_id`:
```json
{"task_id": "0b9c…", "status": "queued", "url": "/v2/tasks/0b9c…"}
```
Без `url` и без открытого окна сессии — `422`.
### GET /v2/tasks/{id}
Только с cookie `session_id` той сессии, в которой задача создана; иначе, а также для несуществующей или истёкшей
задачи — `404`.
```json
{"task_id": "0b9c…", "status": "done", "result": {"status": "ok", "user_agent": "…", "cookies": […], "actions": […], "duration_ms": 7120}}
```
| `status` | Значение |
|---|---|
| `queued` | ждёт свободного слота браузера |
| `running` | выполняется |
| `done` | готово, ответ в `result` — в том же формате, что ответ `/v1/solve` (со своим `status` `ok`/`err`) |
| `failed` | задачу не удалось выполнить (например, воркер упал дважды), причина в `error` |
Результат хранится `RESULT_TTL_HOURS` часов (по умолчанию 24), затем `404`. Опрашивать раз в 0,5–2 секунды.
### Окно браузера между задачами
С `browser_idle_timeout > 0` Chrome сессии не закрывается после задачи. Следующая задача с той же cookie
`session_id` выполняется в этом окне: без запуска Chrome и обычно без повторного антибота, а без `url` — прямо на
текущей странице (например, продолжить заполнение формы). Каждая задача в окно заново отсчитывает простой — по
своему `browser_idle_timeout`; задача с `0` закрывает окно после себя.
* Открытое окно занимает слот браузера, пока не закроется. Не держите окна дольше, чем нужно.
* Задача с другим `proxy`, чем у открытого окна, перезапускает Chrome (прокси задаётся при запуске).
* `cookies` в задаче заменяют cookies открытого окна, как и при запуске.
* Если задача в окне упала с `request_failed`, окно закрывается; следующая задача откроет его заново из профиля.
### DELETE /v2/sessions/current
Закрыть окно сессии (по cookie `session_id`) досрочно и освободить слот. Если в окне идёт задача — сразу после неё.
Ответ `{"closed": true}`, или `{"closed": false}`, если окна нет.
## Производительность
Каждый запрос запускает отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare.
Каждое окно — отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare.
Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка).
+48 -20
View File
@@ -3,20 +3,35 @@
## Быстрый старт
```bash
docker compose up -d --build
docker compose logs -f api
make up # собрать образы и запустить api, worker, rabbitmq, redis
make smoke # проверить: /health, /v1/solve, /v2/tasks, удержание окна
make logs # логи api и worker
make down
```
API доступно на `http://localhost:8000` (`POST /v1/solve`).
API доступно на `http://localhost:8000`.
Без compose:
## Сервисы
```bash
docker build -t robotex-api .
docker run -d --name robotex -p 8000:8000 --shm-size=2g -v robotex-extra:/app/extra robotex-api
```
клиент ─▶ api ──▶ RabbitMQ ──▶ worker (Chrome + Xvfb) ──▶ Redis ◀── api
```
## Что внутри образа
| Сервис | Образ | Что делает |
|---|---|---|
| `api` | `robotex-api` (`Dockerfile.api`, без Chrome) | принимает задачи, ставит в очередь, отдаёт результаты из Redis; `/v1/solve` ждёт результат сам |
| `worker` | `robotex-worker` (`Dockerfile`) | `dramatiq worker.actors`: выполняет задачи в Chrome, держит окна сессий (`browser_idle_timeout`) |
| `rabbitmq` | `rabbitmq:4.1-alpine` | очереди: общая `robotex` и персональная `robotex.worker.<id>` у каждого воркера |
| `redis` | `redis:8.2-alpine` | задачи и результаты (TTL `RESULT_TTL_HOURS`), открытые окна (TTL = простой), heartbeat воркеров |
Наружу открыт только порт `api`. Прокси и ключ 2captcha из запроса передаются только в сообщении очереди и в Redis
не сохраняются.
Воркеров можно запустить несколько (`docker compose up -d --scale worker=2`): каждый берёт задачи из общей очереди,
а задачи сессии с открытым окном api направляет в персональную очередь воркера, где это окно. Профили сессий лежат
в volume воркера — воркеры на одном хосте делят его.
## Что внутри образа worker
| Компонент | Зачем |
|---|---|
@@ -27,11 +42,12 @@ docker run -d --name robotex -p 8000:8000 --shm-size=2g -v robotex-extra:/app/ex
| `tini` (ENTRYPOINT, PID 1) | пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb — без него копятся зомби-процессы |
| пользователь `app` (uid 1000) | Chrome не запускается от root без `--no-sandbox` |
Рабочая директория — `/app/src`, uvicorn запускается как `uvicorn main:app` (импорты в проекте вида `from api import ...`).
Рабочая директория — `/app/src`. Воркер — один процесс dramatiq (`--processes 1`: открытые окна живут в его памяти),
потоков `WORKER_THREADS` (по умолчанию `2 × BROWSER_SLOTS`).
## Данные и volume
`/app/extra` вынесен в volume `extra`:
`/app/extra` воркера вынесен в volume (`extra` локально, `api-data` в релизе):
- `extra/user_data_dir/<session_id>` — профили браузера (cookies, local storage, fingerprint) по `session_id`;
- `extra/geoip_cache.json` — кэш геолокации прокси.
@@ -39,15 +55,18 @@ docker run -d --name robotex -p 8000:8000 --shm-size=2g -v robotex-extra:/app/ex
Профили со временем накапливаются — чистить вручную:
```bash
docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*'
```
## Настройки compose
- `shm_size: 2gb` — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»).
- `API_PORT` — порт на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`.
- `API_CPUS` / `API_MEM_LIMIT` — потолок ресурсов контейнера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
- `shm_size: 2gb` у worker — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»).
- `API_PORT` — порт api на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`.
- `WORKER_CPUS` / `WORKER_MEM_LIMIT` — потолок ресурсов воркера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
При переборе памяти OOM-killer убивает процесс Chrome внутри контейнера, хост не страдает.
- `RABBITMQ_PASSWORD` — пароль пользователя `robotex` в RabbitMQ (по умолчанию `robotex`; порт наружу не открыт).
RabbitMQ запускается с `consumer_timeout` 4 ч: задача может выполняться дольше 30 минут по умолчанию.
- `stop_grace_period: 60s` у worker — при остановке он доделывает текущие задачи и закрывает окна.
- `restart: unless-stopped`.
- `.env` подключается, если существует (шаблон — `env.example`).
@@ -55,8 +74,14 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
| Переменная | По умолчанию | Описание |
|---|---|---|
| `BROWSER_SLOTS` | `10` | сколько Chrome работает параллельно; сверх — `503` |
| `NO_SLOTS_RETRY_AFTER` | `5` | значение `Retry-After` (с) в ответе `503` |
| `BROWSER_SLOTS` | `4` | worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди |
| `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq |
| `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди |
| `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи |
| `V1_QUEUE_WAIT` | `30` | api: сколько `/v1/solve` ждёт, пока воркер возьмёт задачу; дальше `503` |
| `NO_SLOTS_RETRY_AFTER` | `5` | api: значение `Retry-After` (с) в ответе `503` |
| `RABBITMQ_URL` | `amqp://guest:guest@localhost:5672/` | api, worker; в compose задаётся сам |
| `REDIS_URL` | `redis://localhost:6379/0` | api, worker; в compose задаётся сам |
| `LOG_LEVEL` | `INFO` | `DEBUG` — полный дамп запроса/ответа (секреты клиента маскируются) |
## Нагрузка
@@ -80,9 +105,12 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
время растёт в десятки раз, а Cloudflare перестаёт пропускать (на 4 CPU и 10 параллельных — 6 из 10 застряли
на челлендже).
- Память — около **0,5–0,6 ГБ** на браузер.
- Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `API_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`.
Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env` (по умолчанию 10 — на ~10–12 ядер).
- Сверх `BROWSER_SLOTS` запросы сразу получают `503` с `code: no_slots`, не ставятся в очередь.
- Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `WORKER_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`.
Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env`.
- Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU.
- Сверх `BROWSER_SLOTS` задачи v2 ждут в очереди, а `/v1/solve` сразу получает `503` с `code: no_slots`.
Замеры выше сделаны на v1 (один сервис, Chrome в api); схема нагрузки на Chrome с очередью та же.
Повторить замер:
@@ -104,4 +132,4 @@ api.example.com {
}
```
Таймауты прокси должны быть не меньше `timeout` запроса (по умолчанию 120 с).
Таймауты прокси для `/v1/solve` должны быть не меньше `timeout` запроса (по умолчанию 120 с); запросы v2 короткие.
+19 -4
View File
@@ -46,14 +46,29 @@ UA в ответе может измениться, когда на сервер
localStorage, отпечаток), и повторно проходить антибот обычно не нужно — запрос заметно быстрее.
- Одна сессия — один «человек»: держите для неё **один и тот же прокси**. Смена exit-IP внутри сессии выглядит
подозрительно и обнуляет `cf_clearance`.
- Не отправляйте два запроса одной сессии одновременно: профиль Chrome занят первым запросом.
- Задачи одной сессии выполняются по очереди: вторая ждёт, пока закончится первая.
- Для параллельной работы заводите несколько сессий, каждую со своим прокси.
## Синхронно или через задачи
- `/v1/solve` — проще: один запрос, один ответ. Держите соединение открытым до 3–4 минут (см. «Таймауты»).
- `/v2/tasks` — запрос возвращается сразу, результат забираете по ссылке `GET /v2/tasks/{id}` с той же cookie
`session_id`. Подходит, когда соединение нельзя держать долго, и для многошаговых сценариев.
Многошаговый сценарий (войти, затем несколько раз что-то сделать на сайте) — с удержанием окна:
1. `POST /v2/tasks` с `url` и `browser_idle_timeout`: окно останется открытым на это число минут простоя.
2. Следующие задачи с той же cookie `session_id` и тем же `proxy` выполняются в открытом окне: без запуска Chrome
и повторного антибота. Без `url` — на текущей странице.
3. В конце — `DELETE /v2/sessions/current` или задача с `browser_idle_timeout: 0`: окно закроется и освободит слот.
Открытое окно занимает слот браузера, поэтому ставьте простой не больше, чем пауза между вашими шагами.
## Cookies
- Переданные в запросе `cookies` **заменяют** cookies профиля целиком. Не передали — профиль сохраняет свои.
- Для постоянных cookies указывайте `expires` (unix-время). Cookie без `expires` — сессионная и пропадёт между
запросами, потому что браузер перезапускается на каждый запрос.
- Для постоянных cookies указывайте `expires` (unix-время). Cookie без `expires` — сессионная и пропадёт, как
только закроется окно браузера (после задачи без `browser_idle_timeout`).
- Обычно передавать cookies не нужно: профиль сам хранит то, что выдал сайт. Передавайте, только если сайт ведёт
себя по-разному в зависимости от cookies (например, авторизация, полученная вне Robotex).
@@ -61,7 +76,7 @@ UA в ответе может измениться, когда на сервер
| Ответ | Что делать |
|---|---|
| `503`, `code: no_slots` | все браузеры заняты — повторить через `Retry-After` секунд |
| `503`, `code: no_slots` | `/v1/solve`: все браузеры заняты — повторить через `Retry-After` секунд. Задачи v2 в этом случае ждут в очереди |
| `challenge_failed` | антибот не пройден за отведённое время: повторить в той же сессии; при повторных неудачах — сменить прокси (и сессию) |
| `action_failed` | упало действие: смотрите `error` и `actions[].result`; для отладки повторите с `"screenshot": true` |
| `request_failed` | навигация не удалась: обычно прокси недоступен или сайт не отвечает |