23 Commits
Author SHA1 Message Date
protokeyandClaude Opus 5.5 999644883f Клиент: установка по тегу v0.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:42:38 +04:00
protokeyandClaude Opus 5.5 8ccf5b6751 Клиент: установка из master — тег v0.1.0 раньше клиента
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:18:51 +04:00
protokeyandClaude Opus 5.5 cb85e5f3ab 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>
2026-10-13 22:18:40 +04:00
protokeyandClaude Opus 5.5 11ba83140d chore: нагрузочный замер v2, push образов в реестр (#86, #85)
- loadtest: у каждого запроса своя сессия, режим --mode v2 (задача + опрос),
  X-API-Key; замер v1/v2 в docs/Deployment.md.
- make push ver=... — образы robotex-api и robotex-worker в docker.sokolab.xyz;
  prod-compose и make release ссылаются на образы реестра.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:02:59 +04:00
protokeyandClaude Opus 5.5 1a4bd8d672 feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)
- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests.
- Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без
  выпущенных ключей доступа нет. В базе — только sha256 ключа.
- Задача и окно сессии привязаны к клиенту: чужой ключ их не видит.
- Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу
  (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу.
- python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats.
- smoke и loadtest с ключом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:48 +04:00
protokeyandClaude Opus 5.5 9dc2c0b619 feature: шум canvas с seed профиля — отпечатки профилей различаются (#89)
Минимальный init-script (src/api/js/canvas_noise.js): младший бит у ~1/64 непрозрачных
пикселей при toDataURL/toBlob/getImageData, детерминирован от seed профиля. Пустой
canvas не меняется, подменённые функции неотличимы по toString и length. Старые
профили получают seed, экран сохраняется.

Проверено: 4 профиля — 4 разных хеша canvas; Cloudflare 6/6 на новых профилях.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:02:21 +04:00
protokeyandClaude Opus 5.5 4c8394eb4c feature: воркер удаляет профили, не использованные PROFILE_TTL_DAYS дней (#20)
Отметка последнего использования — mtime файла в профиле, ставится при открытии и
закрытии окна. Очистка при старте воркера и раз в час; профили с открытым окном
(на этом воркере или по ключу сессии в Redis) не трогаются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:58:56 +04:00
protokeyandClaude Opus 5.5 1075267d27 feature: действие typing — ввод как у человека, кириллица (#14)
fill остаётся мгновенным. typing подводит курсор по кривой, кликает в поле, стирает
старое значение и набирает текст посимвольно с паузами; символы вне раскладки
(кириллица) больше не роняют ввод. Значение typing маскируется в логах.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:58:09 +04:00
protokey 49a2f3024a feature: действие inner_text; запросы v2 в test_main.http 2026-10-13 20:21:17 +04:00
protokeyandClaude Opus 5.5 6ce8a8c648 chore: окружение по умолчанию в ~/.py_envs/robotex, VENV переопределяется
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:21:17 +04:00
protokeyandClaude Opus 5.5 7b511d5d38 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>
2026-10-13 19:13:52 +04:00
protokeyandClaude Opus 5.5 fe509c25c3 feature!: параметр user_agent убран, UA всегда родной; docs/Usage.md
- user_agent в запросе → 422 с подсказкой брать UA из ответа: UA, отличный от реального
  Chrome, расходится с userAgentData и движком, Cloudflare такой браузер не пропускает
  (замер: родной 3/3, Chrome 147 и Windows — 0) (#87)
- профиль хранит только отпечаток (экран, DPR), UA не подменяется (#89)
- docs/Usage.md: практики для клиентов API — тот же UA и прокси, что у браузера, сессии,
  cookies, ошибки и повторы, таймауты, пример на httpx
- README, docs/API.md, docs/Introduction.md; тесты под новый контракт

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:19:08 +04:00
protokeyandClaude Opus 5.5 0ae5b0c83f test: юнит-тесты и сквозные тесты с Chrome (#21)
- tests/unit (make test, без браузера, ~5 с): схемы и валидация, /v1/solve с подменённым
  движком (коды ошибок, слоты, сессии, UA и cookies профиля, маскирование секретов в логах),
  действия, оркестратор антибота, профиль и отпечаток
- tests/browser (make test-browser, в Docker): example.com, UA один раз и замена cookies
  на httpbin, применение и стабильность отпечатка, прохождение Cloudflare
- requirements-dev.txt, make sync-dev; Makefile вызывает pip через python -m pip
  (у перенесённого .venv битый shebang bin/pip)
- fix: в CloudflareSolver вызов bounding_box() у неожиданной корутины — ветка без iframe
  всегда падала с AttributeError

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:10:21 +04:00
protokeyandClaude Opus 5.5 58100fa45d feature: UA и отпечаток задаются профилю один раз, cookies запроса заменяют профильные
- user_agent записывается в профиль при первом запросе сессии, дальше игнорируется (#87)
- переданные cookies заменяют cookies профиля (clear_cookies перед add_cookies) (#88)
- без user_agent профилю генерируется отпечаток browserforge (Linux Chrome): UA с версией
  установленного Chrome, экран и devicePixelRatio; только нативная эмуляция, без JS (#89)
- make fp-check: проверка, что отпечаток применяется и стабилен, и доля различий профилей

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:23:01 +04:00
protokeyandClaude Opus 5.5 a399f7f8cc docs: README и docs приведены к реализации (#50)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:52:12 +04:00
protokeyandClaude Opus 5.5 437ea27be7 fix: слоты, безопасность, единый формат ответа и валидация
- слоты браузеров: asyncio.Semaphore, размер из BROWSER_SLOTS, при нехватке — 503 + Retry-After;
  устранена утечка счётчика при отказе (#12, #49)
- session_id принимается только как UUID, путь профиля проверяется (#5)
- секреты клиента в логах маскируются, полный дамп — только на DEBUG (#6)
- ответ: status всегда "ok"/"err", поле code (no_slots / request_failed / challenge_failed /
  action_failed), response_model (#7)
- непройденный антибот → challenge_failed, действия не выполняются (#8)
- таймауты провайдеров в мс вынесены в константы; detect переживает навигацию посреди
  проверки; пауза между неудачными solve вместо холостого цикла (#9)
- обязательные поля действий и формат прокси проверяются схемой → 422 до запуска браузера (#13)
- прокси необязателен (#15)
- user_agent сохраняется в профиле сессии и реально передаётся в браузер (#16)
- duration_ms у действий и у запроса (#18), опция screenshot (#19)
- общий конфиг логирования (LOG_LEVEL), print → логгер, CloudflareProvider,
  удалён cached_persistent, GET /health (#22)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:52:12 +04:00
protokey f58306cbb4 chore: настроена команда сборки 2026-10-12 14:20:46 +04:00
protokey b8499e2ce6 chore: updated 2026-10-12 14:11:09 +04:00
protokeyandClaude Opus 5.5 a319b81d8b build: лимиты ресурсов, прокси и нагрузочный тест; docs: API
- docker-compose: cpus/mem_limit через API_CPUS/API_MEM_LIMIT, прокси gost в профиле loadtest
- tests/loadtest.py: параллельные запросы к /v1/solve + пики docker stats
- docs/API.md: справочник по запросу, действиям, ответу и сессиям
- docs/Deployment.md: результаты замеров и правило подбора BROWSER_SLOTS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:55:36 +04:00
protokeyandClaude Opus 5.5 df108dae3f fix: ложный status ok и падение без captcha_type
- без captcha_type шаг антибота пропускается, на неизвестный тип — понятная ошибка
- wait_for и pass_challenge при неудаче падают, а не возвращают текст ошибки как результат
- status "err" ставится по упавшему действию (раньше проверялся несуществующий ключ error)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:55:36 +04:00
protokey c54500b178 chore: улучшена логика работы с учетом слотов 2026-10-12 13:17:34 +04:00
protokey 08a7501d31 docs: задачи теперь в редмайне 2026-10-12 12:56:40 +04:00
protokey 7e148f0393 chore: updated 2026-10-11 00:32:47 +04:00
81 changed files with 5642 additions and 458 deletions

No files matched your search

+4
View File
@@ -5,3 +5,7 @@ __pycache__/
wheels/
docs/*.bkp
docs/*.dtmp
build/
client/dist/
client/build/
client/src/*.egg-info/
+4 -3
View File
@@ -1,3 +1,4 @@
# Сервис worker (#78): выполняет задачи из очереди в Google Chrome под Xvfb.
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -30,9 +31,9 @@ WORKDIR /app/src
# профили браузера (extra/user_data_dir) и geoip-кэш — в volume
VOLUME ["/app/extra"]
EXPOSE 8000
# tini как PID 1: пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb (иначе — зомби)
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", \
"uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# один процесс: открытые окна браузера живут в его памяти; потоков — с запасом над слотами, чтобы
# задачи в уже открытые окна не ждали, пока потоки заняты задачами без свободного слота
CMD ["sh", "-c", "exec xvfb-run -a --server-args='-screen 0 1920x1080x24' dramatiq worker.actors --processes 1 --threads ${WORKER_THREADS:-$((${BROWSER_SLOTS:-4} * 2))}"]
+24
View File
@@ -0,0 +1,24 @@
# Сервис api (#78): принимает задачи и отдаёт результаты. Без Chrome — лёгкий образ.
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements-api.txt .
RUN pip install -r requirements-api.txt \
&& useradd --create-home --uid 1000 app
COPY --chown=app:app src/main.py ./src/
COPY --chown=app:app src/tasks/ ./src/tasks/
COPY --chown=app:app src/db/ ./src/db/
COPY --chown=app:app src/api/__init__.py src/api/schemas.py ./src/api/
USER app
WORKDIR /app/src
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+115 -4
View File
@@ -1,13 +1,22 @@
VENV := .venv
VENV ?= $(HOME)/.py_envs/robotex
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PIP := $(PYTHON) -m pip
WHEELS_DIR := wheels
REQUIREMENTS := requirements.txt
ver ?= latest
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.PHONY: install freeze wheels sync sync-offline clean help venv
.PHONY: install freeze wheels sync sync-offline clean help venv build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push
# образы сервисов: api — без Chrome, worker — Chrome + Xvfb; rabbitmq и redis — сторонние, кладутся в релиз
REGISTRY := docker.sokolab.xyz
API_IMAGE := robotex-api
WORKER_IMAGE := robotex-worker
DEPS_IMAGES := rabbitmq:4.1-alpine redis:8.2-alpine postgres:17-alpine
API_URL ?= http://localhost:8000
API_KEY ?=
help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -16,6 +25,21 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make build [ver=latest] - собрать образы $(API_IMAGE):<ver> и $(WORKER_IMAGE):<ver>"
@echo "make push ver=<ver> - собрать образы и отправить в $(REGISTRY)"
@echo "make release ver=<ver> - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env, образы .tar.gz)"
@echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis"
@echo "make down - остановить локальный стенд"
@echo "make logs - логи api и worker"
@echo "make smoke API_KEY=... [API_URL=...] - проверить запущенный стенд: ключ, /v1/solve, /v2/tasks, удержание окна"
@echo "make key-create name=<клиент> - выпустить API-ключ клиенту (в запущенном compose)"
@echo "make key-list - клиенты и их ключи (без самих ключей)"
@echo "make key-revoke id=<id> - отозвать ключ клиента"
@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:
@test -d $(VENV) || python3 -m venv $(VENV)
@@ -46,6 +70,93 @@ wheels: venv
clean:
rm -rf $(WHEELS_DIR)
build:
docker build -f Dockerfile.api -t $(API_IMAGE):$(ver) .
docker build -t $(WORKER_IMAGE):$(ver) .
push: build
for img in $(API_IMAGE) $(WORKER_IMAGE); do \
docker tag $$img:$(ver) $(REGISTRY)/$$img:$(ver) && docker push $(REGISTRY)/$$img:$(ver) || exit 1; \
done
up: build
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f api worker
smoke:
$(PYTHON) tests/smoke.py $(API_URL) $(API_KEY)
key-create:
docker compose exec api python -m db.cli create "$(name)"
key-list:
docker compose exec api python -m db.cli list
key-revoke:
docker compose exec api python -m db.cli revoke $(id)
days ?= 7
stats:
docker compose exec api python -m db.cli stats --days $(days)
sync-dev: venv
$(PIP) install -r requirements-dev.txt
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 \
-v $(CURDIR)/requirements-dev.txt:/app/requirements-dev.txt:ro \
-w /app $(WORKER_IMAGE):$(ver) sh -c 'pip install -q --user -r requirements-dev.txt && \
xvfb-run -a --server-args="-screen 0 1920x1080x24" python -m pytest -m browser -p no:cacheprovider -s'
RELEASE_DIR := build/$(ver)
ifneq ($(filter release push,$(MAKECMDGOALS)),)
ifeq ($(ver),latest)
$(error Укажите версию: make release ver=v0.0.2)
endif
endif
release: build
@echo "make release $(ver) -> $(RELEASE_DIR)/"
@mkdir -p $(RELEASE_DIR)
sed -e 's|image: .*$(API_IMAGE):.*|image: $(REGISTRY)/$(API_IMAGE):$(ver)|' \
-e 's|image: .*$(WORKER_IMAGE):.*|image: $(REGISTRY)/$(WORKER_IMAGE):$(ver)|' \
docker-compose.prod.yml > $(RELEASE_DIR)/docker-compose.yml
cp dot_env $(RELEASE_DIR)/.env
for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done
# TODO: я хочу парамтером управлять сохранить в файл или отправить в свой реджестри
# if produce == file:
docker tag $(API_IMAGE):$(ver) $(REGISTRY)/$(API_IMAGE):$(ver)
docker tag $(WORKER_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver)
docker save $(REGISTRY)/$(API_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver) $(DEPS_IMAGES) | gzip > $(RELEASE_DIR)/robotex-$(ver).tar.gz
# else:
# docker push docker.sokolab.xyz/$(API_IMAGE):$(ver) docker.sokolab.xyz/$(WORKER_IMAGE):$(ver) docker.sokolab.xyz/$(DEPS_IMAGES)
@printf '%s\n' \
'# Robotex $(ver)' \
'' \
'```bash' \
'docker load -i robotex-$(ver).tar.gz' \
'docker compose up -d' \
'```' \
'' \
'Настройки (порт, слоты, CPU и память воркера, пароли RabbitMQ и Postgres) — в .env.' \
'Ключ клиенту: docker compose exec api python -m db.cli create <имя>' > $(RELEASE_DIR)/README.md
@echo "Готово: $(RELEASE_DIR)/"
# позволяет передавать имя пакета как аргумент: make install redis
%:
@:
+146 -94
View File
@@ -1,83 +1,121 @@
# Robotex
Browser automation API with built-in anti-bot stealth. Send a URL and a sequence of declarative actions — fill a field,
click, wait for an element — and get back the rendered page, including sites behind Cloudflare-style JS challenges.
click, wait for an element — and get back cookies, the rendered page and, optionally, a screenshot, including sites
behind Cloudflare-style JS challenges.
## Status
Early MVP (v0), under active development. Single browser engine, no queue, no billing yet — see [Roadmap](#roadmap).
Early MVP (v0), under active development. Single browser engine, no billing yet. Tasks and project wiki —
[Redmine](https://redmine.sokolab.xyz/projects/robotex).
Every request needs a client API key in `X-API-Key` (keys are issued with `make key-create`). Usage — requests,
their duration, browser time per client — is recorded in Postgres (`make stats`).
## Architecture
```
Client → Caddy (TLS) → FastAPI → API-key auth → semaphore acquire
│
▼
Chrome (Patchright) context
(run actions, collect result)
│
▼
release semaphore → response
Client → Caddy (TLS) → api (FastAPI) ──task──▶ RabbitMQ ──▶ worker (dramatiq, Chrome + Xvfb)
▲ │ POST /v2/tasks common queue browser slots, open windows
│ │ POST /v1/solve or the worker's pass anti-bot → run actions
│ ▼ own queue │
└─── GET /v2/tasks/{id} ◀── Redis: task status and result (TTL) ◀─┘
```
- **Engine**: real Google Chrome (`channel="chrome"`) driven via [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python)
(undetected Playwright fork), headful under Xvfb. Firefox is not supported yet.
- **API**: FastAPI, single versioned endpoint (`POST /v1/solve`).
- **Concurrency**: bounded by an `asyncio.Semaphore` over browser contexts — no external job queue in v1. Simpler to
run, hard ceiling on throughput per instance (see Roadmap for scaling out).
- **Auth**: per-client API key (`X-API-Key` header), checked against a SQLite table. (SQLAlchemy ORM)
- **Logging**: every request's outcome (success/failure/duration) is persisted — this becomes the success-rate data used
to evaluate the service and talk to clients about reliability.
- **Proxies**: not managed by the service in v1 — the client supplies their own proxy per request if they need one.
- **Deployment**: single Docker container behind a Caddy reverse proxy — see [Docker](#docker) and
(undetected Playwright fork), headful under Xvfb. Browser session wrapper ported from scrapling. Firefox is not
supported yet.
- **Two services**: `api` (FastAPI, no browser) registers tasks and serves results; `worker` (dramatiq) runs them in
Chrome. Tasks go through RabbitMQ, statuses and results live in Redis with a TTL.
- **API**: `POST /v2/tasks` + `GET /v2/tasks/{id}` (asynchronous, poll by link), `DELETE /v2/sessions/current`;
`POST /v1/solve` keeps the synchronous contract on top of the same queue; `GET /health`.
- **Concurrency**: each worker has `BROWSER_SLOTS` Chrome windows. Tasks without a free slot wait in the queue;
`/v1/solve` answers `503` with `Retry-After` when no slot is free.
- **Window holding**: with `browser_idle_timeout` (minutes, 0–120) the session's Chrome stays open after the task;
the next task with the same `session_id` is routed to that worker's own queue and runs in the open window, even
without a `url` (on the current page). Idle time is a Redis key TTL; an open window occupies a slot.
- **Anti-bot**: detect → solve → verify loop for Cloudflare Turnstile and reCAPTCHA; captchas inside pages (Turnstile,
reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
- **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
timezone are derived from the proxy's exit IP.
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR, canvas noise seed) is generated on
the first request of a session; cookies passed in a request replace the profile's cookies.
- **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA
mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response.
- **Clients and usage**: Postgres — API keys (sha256 only), a journal of browser windows (opened, last used,
closed) and of requests (queued, started, finished, outcome, task data without secrets). A task and a window are
visible only to the client that created them; no keys — no access.
- **Logging**: stdout, level from `LOG_LEVEL`; client secrets (proxy credentials, 2captcha key, `fill` values,
cookie values) are masked.
- **Deployment**: Docker Compose — `api`, `worker`, `rabbitmq`, `redis`, `postgres`; TLS is terminated by Caddy on the host — see
[docs/Deployment.md](docs/Deployment.md).
- **Canvas fingerprint** по дизайну генерируется каждую сессию новый.
### Project layout
```
src/
main.py # FastAPI app, POST /v1/solve
main.py # api service: /v2/tasks, /v1/solve, /health, sessions
db/ # Postgres, shared by api and worker
models.py # clients, windows, requests
clients.py # API keys: issue, check, revoke
journal.py # worker: windows and requests journal
stats.py, cli.py # usage report; python -m db.cli create|list|revoke|stats
tasks/ # shared by api and worker, no browser
store.py # Redis: tasks, results, open windows, worker heartbeats
broker.py # dramatiq broker (RabbitMQ), queues, messages
sessions.py # session_id cookie
worker/
actors.py # dramatiq actors, worker lifecycle, heartbeat
windows.py # browser slots, windows held between tasks, task execution
api/
actions.py # fill / click / wait_for execution
schemas.py # SolveRequest / Action models
geoip.py # proxy → locale / timezone
engine/
stealthy.py # AsyncStealthySession (Patchright + Chrome)
session.py # browser / context launch options
page_pool.py # page pool
antibot/ # anti-bot detection & bypass orchestrator (Cloudflare, reCAPTCHA)
captcha/ # captcha solvers (Turnstile, reCAPTCHA, hCaptcha)
profile.py # per-profile generated fingerprint
schemas.py # SolveRequest / TaskRequest / ActionRequest / responses + validation
actions.py # action registry and execution
geoip.py # proxy → locale / timezone
logs.py # request/response dumps without client secrets
engine/ # Chrome session wrapper (ported from scrapling)
stealthy.py # AsyncStealthySession
session.py # browser / context launch options
navigation.py, page_pool.py, validators.py, proxy_rotation.py, ad_domains.py, ...
antibot/
orchestrator.py # detect → solve → verify loop
registry.py, base.py
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/
wheels/ # whl библиотеки для оффлайн установки под Ubuntu Noble
Dockerfile
docker-compose.yml
.dockerignore
requirements.txt
env.example
unit/ # pytest without a browser: make test
browser/ # end-to-end with real Chrome in Docker: make test-browser
harness.py # api + worker in one process (fakeredis, no RabbitMQ) for tests
smoke.py # check a running stack: make smoke
loadtest.py # concurrent load test + docker stats
test_main.http # manual requests (JetBrains HTTP client)
wheels/ # wheels for offline install on Ubuntu Noble
Dockerfile # worker: Chrome + Xvfb
Dockerfile.api # api: no browser
docker-compose.yml # local / load testing
docker-compose.prod.yml # template for releases (make release)
Makefile
```
## API (draft)
## API
Synchronous:
```
POST /v1/solve
X-API-Key: <key>
{
"captcha": "token",
"url": "https://example.com/login",
"url": "https://freelancehunt.com/projects",
"proxy": "http://user:pass@host:port",
"captcha_type": "cloudflare",
"actions": [
{"type": "fill", "selector": "#email", "value": "user@example.com"},
{"type": "fill", "selector": "#password", "value": "..."},
{"type": "mouse_move", "selector": "#submit"},
{"type": "click", "selector": "#submit"},
{"type": "wait_for", "selector": ".dashboard", "timeout": 30}
{"type": "wait_for", "selector": "a[href*=\"/project/\"]", "timeout": 60}
],
"proxy": "socks5://user:pass@host:port",
"screen": "1280x920",
"user_agent": "Mozilla/5.0 (...) Chrome/...",
"timeout": 120
"html": true
}
```
@@ -85,71 +123,85 @@ Response:
```json
{
"ok": false,
"context": "000000",
"error": "action_error",
"html": "<html>...</html>",
"cookies": [
{
"name": "session",
"value": "..."
}
],
"actions_result": [
{
"type": "fill",
"ok": true
},
{
"type": "fill",
"ok": true
},
{
"type": "click",
"ok": true
},
{
"type": "wait_for",
"ok": false,
"error": "Element not located"
}
]
"status": "ok",
"user_agent": "Mozilla/5.0 (...) Chrome/155.0.0.0 Safari/537.36",
"cookies": [{"name": "cf_clearance", "value": "..."}],
"actions": [{"type": "wait_for", "ok": true, "duration_ms": 14}],
"html": "<head>...</head><body>...</body>",
"duration_ms": 6405
}
```
On failure, `status` is `"error"` and `error` carries a machine-readable reason (`selector_not_found`, `timeout`,
`navigation_failed`, ...).
On failure `status` is `"err"` with a machine-readable `code` (`no_slots`, `request_failed`, `challenge_failed`,
`action_failed`) and a human-readable `error`.
Fill команда включает 3 действия
Asynchronous, keeping the browser window open between tasks:
1. `{"type": "mouse_move", "selector": "#email"},` - навести мышку на элемент
2. `{"type": "click"},` - клик в текущие координаты
3. `{"type": "typing", "value": "user@example.com"},` - набор текста
```
POST /v2/tasks {"url": "https://example.com/login", "actions": [...], "browser_idle_timeout": 10}
→ 202 {"task_id": "...", "status": "queued", "url": "/v2/tasks/..."} + Set-Cookie: session_id
GET /v2/tasks/{id} → {"status": "queued|running|done|failed", "result": {... same as /v1/solve ...}}
POST /v2/tasks {"actions": [...], "browser_idle_timeout": 10} # same session: runs in the open window
DELETE /v2/sessions/current # close the window, free the slot
```
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
Deliberately out of scope for v1:
Not done yet:
- Job queue (Redis/RabbitMQ) + multiple worker VPS for horizontal scaling
- Workers on several hosts (needs shared or per-host routing of browser profiles)
- Real billing/usage plans beyond a flat API-key check
- Broader action vocabulary beyond fill/click/wait_for
- Broader action vocabulary
- Second engine (Firefox, e.g. Camoufox) for targets where the Chrome fingerprint doesn't fit
## Local development
```bash
python -m venv .venv
source .venv/bin/activate
make sync-offline
cd src && uvicorn main:app --reload
make sync-offline # venv in ~/.py_envs/robotex; another path: make VENV=... <target>
docker compose up -d rabbitmq redis # queue and storage
cd src && RABBITMQ_URL=... REDIS_URL=... uvicorn main:app --reload
cd src && dramatiq worker.actors --processes 1 --threads 4
```
The compose network is not published, so for running the services on the host publish `5672` and `6379` yourself,
or just use `make up`. Chrome runs headful, so a display is required (locally — your desktop, in Docker — Xvfb).
Tests:
```bash
make sync-dev # pytest into .venv
make test # unit tests: api + worker + dramatiq, no browser, no RabbitMQ/Redis (~10 s)
make test-browser # build the images and run end-to-end tests with real Chrome (needs network)
make smoke # check the running stack from make up
```
## Docker
```bash
docker compose up -d --build
make up # build robotex-api and robotex-worker, start api, worker, rabbitmq, redis
make smoke # health, /v1/solve, /v2/tasks, window holding
make logs
make down
```
The API listens on `http://localhost:8000`. Browser profiles (`extra/user_data_dir`) and the GeoIP cache live in the
`extra` named volume, so sessions survive container restarts. Details (Xvfb, tini, `/dev/shm`) —
[docs/Deployment.md](docs/Deployment.md).
worker's `extra` named volume, so sessions survive container restarts.
Releases:
```bash
make build ver=v0.1.0 # docker images robotex-api:v0.1.0 and robotex-worker:v0.1.0
make release ver=v0.1.0 # images + deploy bundle in build/v0.1.0/ (compose, .env, images .tar.gz)
```
Details (Xvfb, tini, `/dev/shm`, env settings, load measurements) — [docs/Deployment.md](docs/Deployment.md).
-15
View File
@@ -1,15 +0,0 @@
# Список задач
## Срочные и Важные
-[ ] не удается пройти cf_turnstile
## Не срочные и Важные
-[ ] добавить пакет обертку для python
## Срочные и Не важные
## Не срочные и Не срочные
-[ ]
+104
View File
@@ -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"}`.
+20
View File
@@ -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"]
+30
View File
@@ -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",
]
+94
View File
@@ -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
+69
View File
@@ -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")
+174
View File
@@ -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
+173
View File
@@ -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
+59
View File
@@ -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
+89
View File
@@ -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,
)
View File
Whitespace-only changes.
+91
View File
@@ -0,0 +1,91 @@
x-backend-env: &backend-env
RABBITMQ_URL: amqp://robotex:${RABBITMQ_PASSWORD:-robotex}@rabbitmq:5672/
REDIS_URL: redis://redis:6379/0
DATABASE_URL: postgresql+asyncpg://robotex:${POSTGRES_PASSWORD:-robotex}@postgres:5432/robotex
services:
api:
image: docker.sokolab.xyz/robotex-api:latest
restart: unless-stopped
ports:
- "${API_PORT:-8000}:8000"
env_file:
- path: .env
required: false
environment: *backend-env
depends_on:
rabbitmq: {condition: service_healthy}
redis: {condition: service_healthy}
postgres: {condition: service_healthy}
worker:
image: docker.sokolab.xyz/robotex-worker:latest
restart: unless-stopped
env_file:
- path: .env
required: false
environment: *backend-env
volumes:
- api-data:/app/extra
# Chrome активно использует /dev/shm, дефолтных 64MB не хватает
shm_size: "2gb"
# Потолок ресурсов: при переборе OOM-killer прибьёт процесс Chrome внутри контейнера,
# а не соседние сервисы на хосте. Подбирать под сервер вместе с BROWSER_SLOTS.
cpus: ${WORKER_CPUS:-4}
mem_limit: ${WORKER_MEM_LIMIT:-8g}
memswap_limit: ${WORKER_MEM_LIMIT:-8g}
# при остановке воркер дожидается текущих задач и закрывает окна
stop_grace_period: 60s
depends_on:
rabbitmq: {condition: service_healthy}
redis: {condition: service_healthy}
postgres: {condition: service_healthy}
rabbitmq:
image: rabbitmq:4.1-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: robotex
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-robotex}
# задача выполняется до 3 ч, а по умолчанию RabbitMQ рвёт канал, если сообщение не подтверждено 30 мин
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: -rabbit consumer_timeout 14400000
volumes:
- rabbitmq:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 10
# клиенты и API-ключи, журнал окон и запросов
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: robotex
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-robotex}
POSTGRES_DB: robotex
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "robotex", "-d", "robotex"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:8.2-alpine
restart: unless-stopped
volumes:
- redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
api-data:
rabbitmq:
redis:
postgres:
+90 -2
View File
@@ -1,17 +1,105 @@
# Локальный запуск и тесты: docker compose up -d --build
# Шаблон для релизов — docker-compose.prod.yml (make release).
x-backend-env: &backend-env
RABBITMQ_URL: amqp://robotex:${RABBITMQ_PASSWORD:-robotex}@rabbitmq:5672/
REDIS_URL: redis://redis:6379/0
DATABASE_URL: postgresql+asyncpg://robotex:${POSTGRES_PASSWORD:-robotex}@postgres:5432/robotex
services:
api:
build: .
image: robotex-api
build:
context: .
dockerfile: Dockerfile.api
image: robotex-api:latest
restart: unless-stopped
ports:
- "${API_PORT:-8000}:8000"
env_file:
- path: .env
required: false
environment: *backend-env
depends_on:
rabbitmq: {condition: service_healthy}
redis: {condition: service_healthy}
postgres: {condition: service_healthy}
worker:
build: .
image: robotex-worker:latest
restart: unless-stopped
env_file:
- path: .env
required: false
environment: *backend-env
volumes:
- extra:/app/extra
# Chrome активно использует /dev/shm, дефолтных 64MB не хватает
shm_size: "2gb"
# Потолок ресурсов: при переборе OOM-killer прибьёт процесс Chrome внутри контейнера,
# а не соседние сервисы на хосте. Подбирать под сервер вместе с BROWSER_SLOTS.
cpus: ${WORKER_CPUS:-4}
mem_limit: ${WORKER_MEM_LIMIT:-8g}
memswap_limit: ${WORKER_MEM_LIMIT:-8g}
# при остановке воркер дожидается текущих задач и закрывает окна
stop_grace_period: 60s
depends_on:
rabbitmq: {condition: service_healthy}
redis: {condition: service_healthy}
postgres: {condition: service_healthy}
rabbitmq:
image: rabbitmq:4.1-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: robotex
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-robotex}
# задача выполняется до 3 ч, а по умолчанию RabbitMQ рвёт канал, если сообщение не подтверждено 30 мин
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: -rabbit consumer_timeout 14400000
volumes:
- rabbitmq:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 10
# клиенты и API-ключи, журнал окон и запросов
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: robotex
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-robotex}
POSTGRES_DB: robotex
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "robotex", "-d", "robotex"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:8.2-alpine
restart: unless-stopped
volumes:
- redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
# HTTP-прокси для локальных тестов (exit-IP = IP хоста): docker compose --profile loadtest up -d
# из контейнера worker доступен как http://proxy:8080
proxy:
image: gogost/gost:3.1.0
profiles: ["loadtest"]
restart: unless-stopped
command: ["-L", "http://:8080"]
volumes:
extra:
rabbitmq:
redis:
postgres:
+232
View File
@@ -0,0 +1,232 @@
# API
Как встраивать API в свой клиент (UA, прокси, сессии, повторы) — [Usage.md](Usage.md).
Задача открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит антибот
(Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и, по запросу,
HTML и скриншот. Задачи выполняет сервис worker из очереди; API работает в двух режимах:
* `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` (выполняемые задачи).
## Авторизация
Все эндпоинты, кроме `/health`, требуют ключ клиента в заголовке `X-API-Key`. Без ключа, с неверным или отозванным
ключом — `401`. Ключи выпускает администратор (`make key-create name=<клиент>`, см. [Deployment.md](Deployment.md#клиенты-и-ключи)).
Задача и окно сессии видны только клиенту, который их создал: с другим ключом `GET /v2/tasks/{id}` даёт `404`, а
чужое открытое окно не используется.
## Запрос
```http
POST /v1/solve
Content-Type: application/json
X-API-Key: <key>
Cookie: session_id=<id> # необязательно, см. «Сессии»
{
"url": "https://freelancehunt.com/projects",
"proxy": "http://user:pass@host:port",
"captcha_type": "cloudflare",
"actions": [
{"type": "wait_for", "selector": "a[href*=\"/project/\"]", "timeout": 60}
],
"html": true,
"timeout": 120
}
```
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `url` | string | — | **обязательно**. Страница, которую открываем |
| `proxy` | string | `null` | `scheme://[user:pass@]host:port`, схема `http`, `https` или `socks5` (Chrome не поддерживает socks5 с логином/паролем). По exit-IP прокси выставляются locale и timezone браузера; без прокси — настройки браузера по умолчанию |
| `actions` | array | — | **обязательно**, может быть пустым. Действия, см. ниже |
| `captcha_type` | string | `null` | провайдер антибота: `cloudflare` или `recaptcha`. Без него шаг антибота пропускается — для сайтов за Cloudflare обязательно |
| `captcha` | string | `null` | ключ сервиса распознавания (2captcha), если антибот не проходится без решения капчи |
| `captcha_detect_locator` | string | `null` | локатор, по которому определяется, что на странице антибот |
| `captcha_success_locator` | string | `null` | локатор, по которому определяется, что антибот пройден |
| `html` | bool | `false` | вернуть HTML страницы после выполнения действий |
| `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота |
| `timeout` | int, с | `60` | таймаут навигации |
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` или `{name, value, url}`. **Заменяют** cookies профиля: перед установкой все cookies профиля удаляются (`[]` — просто очистить). Без поля cookies профиля сохраняются |
| `disable_resources` | bool | `false` | не грузить картинки, шрифты, медиа |
| `blocked_domains` | array | `null` | домены, запросы к которым блокируются |
| `retries` | int | `1` | попыток навигации |
| `retry_delay` | float, с | `1.0` | пауза между попытками |
| `wait` | int, мс | `0` | пауза после загрузки страницы |
| `google_search` | bool | `false` | выставить Referer как переход из поиска Google |
### Действия
Выполняются по порядку после прохождения антибота. Если антибот не пройден, действия не выполняются
(`code: challenge_failed`). На первой ошибке действия выполнение останавливается.
Обязательные поля проверяются до запуска браузера: неизвестный `type`, нет `selector`/`value`, `captcha` без ключа
в запросе, неверный `proxy` — ответ `422` с описанием в `detail` (стандартный формат FastAPI).
| `type` | Поля | Что делает |
|---|---|---|
| `wait_for` | `selector`, `timeout` (1–7200 с, по умолчанию 30) | ждёт появления элемента; при таймауте — ошибка действия |
| `click` | `selector` | клик по элементу |
| `submit` | `selector` | клик и ожидание навигации |
| `fill` | `selector`, `value` | мгновенная вставка значения в поле |
| `typing` | `selector`, `value` | ввод как у человека: подвод курсора, клик, очистка поля, посимвольный набор с паузами 50–200 мс; медленнее `fill`, но не выдаёт автоматизацию |
| `inner_text` | `selector` | возвращает текст элемента (HTML без тегов) в `result` |
| `mouse_move` | `selector` | наведение курсора на элемент; нет элемента — ошибка `selector not found` |
| `delay` | `value` (секунды строкой, 0–600) | пауза |
| `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` |
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` в запросе |
| `pass_challenge` | `selector` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия |
## Ответ
Формат один для всех случаев: `status` — `"ok"` или `"err"`, при ошибке — `code` и `error`. Поля без значения не
возвращаются.
Успех (антибот пройден, действия выполнены), HTTP `200`:
```json
{
"status": "ok",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) ... Chrome/155.0.0.0 Safari/537.36",
"cookies": [{"name": "cf_clearance", "value": "...", "domain": ".freelancehunt.com", "...": "..."}],
"actions": [
{"type": "wait_for", "ok": true, "duration_ms": 14}
],
"html": "<head>...</head><body>...</body>",
"duration_ms": 6405
}
```
| Поле | Описание |
|---|---|
| `status` | `"ok"` — антибот пройден и все действия выполнены, иначе `"err"` |
| `code` | при `"err"`: причина, см. таблицу ниже |
| `error` | при `"err"`: текст ошибки; для упавшего действия — `<type>: <текст>` |
| `user_agent` | родной UA браузера, с которым проходили антибот. Используйте его в своём HTTP-клиенте вместе с `cf_clearance` (см. [Usage.md](Usage.md)) |
| `cookies` | все cookies контекста |
| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки), `duration_ms` |
| `html` | только при `"html": true` |
| `screenshot` | только при `"screenshot": true`: PNG в base64 |
| `duration_ms` | общее время обработки запроса |
| `code` | HTTP | Когда | Что ещё есть в ответе |
|---|---|---|---|
| `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` до упавшего включительно |
Примеры:
```json
{"status": "err", "code": "action_failed", "error": "inner_html: selector not found: #missing",
"actions": [{"type": "inner_html", "ok": false, "result": "selector not found: #missing", "duration_ms": 26}], "...": "..."}
```
```json
{"status": "err", "code": "challenge_failed", "error": "cloudflare not passed: timeout (stage: checkbox)", "actions": [], "...": "..."}
```
```json
{"status": "err", "code": "no_slots", "error": "No slots available"}
```
## Сессии
Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем
(`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
Сессии изолированы друг от друга: у каждой свой профиль Chrome — cookies, localStorage, кэш.
Отпечаток профиля генерируется при первом запросе сессии и дальше не меняется (`robotex_session.json` в каталоге
профиля):
* размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge) —
нативной эмуляцией Chrome;
* шум canvas: свой seed у каждого профиля, поэтому хеш canvas у профилей разный, а внутри профиля стабильный.
Меняется младший бит у редких непрозрачных пикселей при чтении canvas (`toDataURL`, `toBlob`, `getImageData`);
пустой canvas не меняется.
Остальное — WebGL, `hardwareConcurrency`, шрифты, UA — не подменяется: это родные значения Chrome, за маскировку
автоматизации отвечает Patchright. Полная JS-подмена отпечатка ломала прохождение Cloudflare.
User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)).
Поле `user_agent` в запросе даёт `422`.
Задачи одной сессии выполняются по очереди: профиль 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.
Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка).
+146 -14
View File
@@ -3,20 +3,67 @@
## Быстрый старт
```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:
## Сервисы
```
клиент ─▶ 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 воркеров |
| `postgres` | `postgres:17-alpine` | клиенты и API-ключи, журнал окон браузера и запросов |
Наружу открыт только порт `api`. Прокси и ключ 2captcha из запроса передаются только в сообщении очереди и в Redis
не сохраняются.
Воркеров можно запустить несколько (`docker compose up -d --scale worker=2`): каждый берёт задачи из общей очереди,
а задачи сессии с открытым окном api направляет в персональную очередь воркера, где это окно. Профили сессий лежат
в volume воркера — воркеры на одном хосте делят его.
## Клиенты и ключи
Без ключей API никого не пускает. Ключ показывается один раз при выпуске, в базе хранится только sha256.
```bash
docker build -t robotex-api .
docker run -d --name robotex -p 8000:8000 --shm-size=2g -v robotex-extra:/app/extra robotex-api
make key-create name=acme # выпустить ключ клиенту
make key-list # клиенты: начало ключа, активен или отозван, последнее обращение
make key-revoke id=3 # отозвать
```
## Что внутри образа
На сервере без make: `docker compose exec api python -m db.cli create acme` (и `list`, `revoke <id>`, `stats`).
## Статистика использования
Воркер пишет в Postgres:
- `windows` — окно браузера: клиент, задача, открывшая окно, время открытия, последнего обращения и закрытия;
- `requests` — задача: клиент, `v1`/`v2`, время постановки в очередь, начала и конца, итог (`status`, `code`,
`duration_ms`), данные задачи без секретов (без логина и пароля прокси, ключа 2captcha, вводимых значений и значений
cookies);
- `clients` — время создания клиента и последнего обращения.
Сводка по клиентам — число запросов, успешные, среднее и p95 время, окна и часы работы браузера, ошибки по `code`:
```bash
make stats days=7
```
Ошибка записи в журнал не роняет задачу — только предупреждение в логе воркера. Схема создаётся при старте api и
воркера.
## Что внутри образа worker
| Компонент | Зачем |
|---|---|
@@ -27,28 +74,113 @@ 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` — кэш геолокации прокси.
Профили со временем накапливаются — чистить вручную:
Воркер удаляет профили, которыми не пользовались `PROFILE_TTL_DAYS` дней (по умолчанию 7): при старте и затем раз
в час. Профиль с открытым окном не трогается. Клиент, вернувшийся позже, получает новый профиль и заново проходит
антибот. Удалить все профили вручную:
```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`.
- `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`; порт наружу не открыт).
- `POSTGRES_PASSWORD` — пароль пользователя `robotex` в Postgres (по умолчанию `robotex`; порт наружу не открыт).
RabbitMQ запускается с `consumer_timeout` 4 ч: задача может выполняться дольше 30 минут по умолчанию.
- `stop_grace_period: 60s` у worker — при остановке он доделывает текущие задачи и закрывает окна.
- `restart: unless-stopped`.
- `.env` подключается, если существует (шаблон — `env.example`).
## Переменные окружения приложения
| Переменная | По умолчанию | Описание |
|---|---|---|
| `BROWSER_SLOTS` | `4` | worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди |
| `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq |
| `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди |
| `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи |
| `PROFILE_TTL_DAYS` | `7` | worker: через сколько дней без использования удаляется профиль браузера |
| `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 задаётся сам |
| `DATABASE_URL` | `postgresql+asyncpg://robotex:robotex@localhost:5432/robotex` | api, worker; в compose задаётся сам |
| `LOG_LEVEL` | `INFO` | `DEBUG` — полный дамп запроса/ответа (секреты клиента маскируются) |
## Нагрузка
Замер: `freelancehunt.com/projects` за Cloudflare Turnstile, `captcha_type: cloudflare`, ожидание списка проектов,
каждый запрос — новый профиль. Прокси — `gost` из профиля `loadtest`. Хост: 16 ядер, 31 ГБ.
| Лимит | Параллельно | Успешно | Медиана, с | Пик CPU | Пик RAM |
|---|---|---|---|---|---|
| 4 CPU | 1 | 1/1 | 5 | 3,5 ядра | 0,5 ГБ |
| 4 CPU | 5 | 3/5 | 68 | 4,1 | 2,8 ГБ |
| 4 CPU | 10 | 4/10 | 229 | 4,2 | 6,0 ГБ |
| 4 CPU | 15 | 3/15 | 261 | 4,2 | 6,1 ГБ |
| 12 CPU | 3 | 3/3 | 8 | 7,2 | 1,4 ГБ |
| 12 CPU | 5 | 4/5 | 8 | 12,1 | 2,4 ГБ |
| 12 CPU | 10 | 9/10 | 23 | 12,3 | 4,7 ГБ |
Выводы:
- Узкое место — **CPU**. Один Chrome на прохождении Turnstile съедает 2–3,5 ядра. Когда процессора не хватает,
время растёт в десятки раз, а Cloudflare перестаёт пропускать (на 4 CPU и 10 параллельных — 6 из 10 застряли
на челлендже).
- Память — около **0,5–0,6 ГБ** на браузер.
- Правило подбора: `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).
### Замер v2 (очередь, 2026-10-13)
Те же условия: freelancehunt за Cloudflare Turnstile, каждый запрос — новая сессия, прокси `gost`. Воркер:
4 CPU, 8 ГБ, `BROWSER_SLOTS=4`. Хост: 16 ядер.
| Режим | Параллельно | Успешно | Медиана, с | Всё, с | Пик CPU | Пик RAM | Прочее |
|---|---|---|---|---|---|---|---|
| `/v1/solve` | 1 | 1/1 | 7 | 7 | 2,5 ядра | 0,6 ГБ | |
| `/v1/solve` | 4 | 4/4 | 40 | 42 | 4,2 | 2,1 ГБ | |
| `/v1/solve` | 8 | 4/8 | 41 | 42 | 4,2 | 2,2 ГБ | 4 — сразу `503 no_slots` |
| `/v2/tasks` | 4 | 3/4 | 40 | 144 | 4,2 | 2,2 ГБ | 1 застрял на Cloudflare |
| `/v2/tasks` | 8 | 8/8 | 72 | 157 | 4,2 | 2,2 ГБ | лишние 4 ждали в очереди |
Выводы:
- Очередь работает как задумано: сверх слотов v1 отказывает сразу, v2 ставит задачи в очередь и выполняет все.
- Узкое место прежнее — CPU: 4 одновременных прохождения Cloudflare на 4 ядрах идут ~40 с вместо 7. Если почти
все задачи проходят Cloudflare, ставьте `BROWSER_SLOTS ≈ ядер / 2`; удерживаемые окна (`browser_idle_timeout`)
CPU почти не тратят, под них слоты можно добавить.
Повторить:
```bash
API_PORT=8001 docker compose --profile loadtest up -d
make key-create name=load
python tests/loadtest.py --api http://127.0.0.1:8001 --api-key rbx_... --levels 1 4 8
python tests/loadtest.py --api http://127.0.0.1:8001 --api-key rbx_... --mode v2 --levels 4 8
```
Параметры скрипта: `--mode v1|v2`, `--api-key`, `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`.
Каждый запрос создаёт новый профиль в `extra/user_data_dir`; воркер удалит их через `PROFILE_TTL_DAYS`.
## Reverse proxy
В проде контейнер ставится за Caddy (TLS). Пример `Caddyfile`:
@@ -59,4 +191,4 @@ api.example.com {
}
```
Таймауты прокси должны быть не меньше `timeout` запроса (по умолчанию 120 с).
Таймауты прокси для `/v1/solve` должны быть не меньше `timeout` запроса (по умолчанию 120 с); запросы v2 короткие.
+5 -56
View File
@@ -1,60 +1,9 @@
# WebRobo API
# Robotex
Сервис позволяет отработать на странице JS и продолжить парсинг. Полезно для ботов, которым нужно авторизоваться на сайте или пройти капчу, там где требуется рендеринг JS.
### Запрос и ответ
Клиент присылает URL, список действий и, при необходимости, свой прокси. Сервис открывает страницу в настоящем Chrome, проходит
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет действия и возвращает cookies (включая `cf_clearance`),
User-Agent и, по запросу, HTML. С этими cookies и UA клиент продолжает парсинг своим HTTP-клиентом.
**Параметры запроса**:
- URL - обязательно нужно указать с каким сайтом будем работать
- proxy - обязательно нужно задать прокси, система не станет палить свою инфраструктуру
- time_zone - временная зона
- locale - языковая настройка
- user_agent - User-Agent браузера Chrome, если не передать будет использован свой
- screen - размер окна, по умолчанию: 1280х920
- timeout - общий таймаут выполнения запроса, по умолчанию: 120
- captcha - токен сервиса распознавания, если требуется решить капчу на странице
- actions - перечисление списка действий, которые на странице нужно выполнить
Настройка actions:
- type - тип запроса из перечисления
- selector - css селектор
- value - значение для вставки
- timeout - время выполнения или ожидания
Доступные типы actions:
- wait_for - ожидание появления элемента
- selector
- timeout
- mouse_move - перемещение курсора
- selector
- click - клик
- selector - если не передан, то клик в текущей позиции
- fill - заполняет поля
- selector
- value - значение будет впечатано
Будет добавлено:
1. `{"type": "typing", "value": "user@example.com"},` - набор текста
2. `{"type": "innerhtml", "selector": "#email"},` - получить контент
3. `{"type": "script", "source": "console.log(\"hello world\")"},` - выполнить скрипт
**Ответ**:
- URL - итоговый урл из адресной строки на момент окончания работы
- user_agent - заголовок браузера
- screen - размер окна
- exc_time = время выполнения
- actions_result - отчет о выполненых действиях
- ok - выполнился ли этот запрос успешно
- error - для сообщения об ошибке
Результат отработки actions
- type - тип события
- ok - успешность события
- error - появляется, когда событие сработало с ошибкой
- message - появляется, для команд возвращающих результат
Как пользоваться API — [Usage.md](Usage.md), описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
+138
View File
@@ -0,0 +1,138 @@
# Использование API: практики для клиентов
Как встроить Robotex в свой парсер или бота, чтобы антибот проходился стабильно, а полученные cookies работали.
Справочник по полям и ответам — [API.md](API.md).
## Основной сценарий
Robotex проходит антибот в настоящем браузере и отдаёт то, с чем клиент дальше ходит на сайт **своим HTTP-клиентом**:
1. `POST /v1/solve` с `url`, своим `proxy` и `captcha_type`.
2. Из ответа взять `cookies` (среди них `cf_clearance`) и **`user_agent`**.
3. Дальше ходить на сайт своим HTTP-клиентом:
- с **тем же User-Agent**, что пришёл в ответе;
- через **тот же прокси** (тот же exit-IP);
- с полученными cookies.
4. Когда сайт снова отвечает челленджем (обычно `403` и страница «Just a moment…») или истекает `cf_clearance`
(поле `expires` у cookie) — повторить `/v1/solve` **в той же сессии** (cookie `session_id`).
`cf_clearance` выдаётся конкретному браузеру: Cloudflare связывает его с User-Agent и, как правило, с IP. С другим UA
или через другой прокси cookie не сработает, и сайт снова покажет челлендж.
## Что задаёт сервис, а не клиент
User-Agent, размер окна и экран, отпечаток браузера **не настраиваются запросом** — их определяет сам браузер.
Клиент подстраивается под ответ, а не наоборот.
Почему так: подменить можно только часть признаков. Строка UA меняется, но `navigator.userAgentData`, client hints и
поведение движка остаются от реального Chrome, и Cloudflare видит расхождение. Замер на Chrome 155 / Linux:
| User-Agent в браузере | Cloudflare |
|---|---|
| родной (Linux Chrome 155) | пройден 3 из 3, 7–9 с |
| Linux Chrome 147 | не пройден, 3 из 3 |
| Windows Chrome 155 | не пройден |
Поэтому параметр `user_agent` убран из запроса (передача даёт `422`). Раньше по той же причине был убран параметр
размера окна. Каждому профилю сервис сам генерирует правдоподобный отпечаток (экран и `devicePixelRatio` из
реального распределения) и применяет его средствами Chrome.
UA в ответе может измениться, когда на сервере обновится Chrome. Не храните UA у себя навсегда — берите его из
последнего ответа.
## Сессии
- Ответ ставит cookie `session_id`. Передавайте её в следующих запросах: профиль браузера сохраняется (cookies,
localStorage, отпечаток), и повторно проходить антибот обычно не нужно — запрос заметно быстрее.
- Одна сессия — один «человек»: держите для неё **один и тот же прокси**. Смена exit-IP внутри сессии выглядит
подозрительно и обнуляет `cf_clearance`.
- Задачи одной сессии выполняются по очереди: вторая ждёт, пока закончится первая.
- Для параллельной работы заводите несколько сессий, каждую со своим прокси.
## Синхронно или через задачи
- `/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` — сессионная и пропадёт, как
только закроется окно браузера (после задачи без `browser_idle_timeout`).
- Обычно передавать cookies не нужно: профиль сам хранит то, что выдал сайт. Передавайте, только если сайт ведёт
себя по-разному в зависимости от cookies (например, авторизация, полученная вне Robotex).
## Ошибки и повторы
| Ответ | Что делать |
|---|---|
| `503`, `code: no_slots` | `/v1/solve`: все браузеры заняты — повторить через `Retry-After` секунд. Задачи v2 в этом случае ждут в очереди |
| `challenge_failed` | антибот не пройден за отведённое время: повторить в той же сессии; при повторных неудачах — сменить прокси (и сессию) |
| `action_failed` | упало действие: смотрите `error` и `actions[].result`; для отладки повторите с `"screenshot": true` |
| `request_failed` | навигация не удалась: обычно прокси недоступен или сайт не отвечает |
| `401` | нет ключа в `X-API-Key`, ключ неверный или отозван |
| `422` | ошибка в запросе: описание в `detail` |
Успех — строго `status == "ok"`.
## Таймауты
Прохождение антибота может занять до 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
import httpx
ROBOTEX = "https://app.robotex.sokolab.xyz"
PROXY = "http://user:pass@host:port"
robotex = httpx.Client(base_url=ROBOTEX, timeout=300, headers={"X-API-Key": "rbx_..."})
def solve(url: str) -> dict:
resp = robotex.post("/v1/solve", json={
"url": url,
"proxy": PROXY,
"captcha_type": "cloudflare",
}) # session_id сохраняется в cookies клиента robotex и уходит в следующих запросах
data = resp.json()
if data.get("status") != "ok":
raise RuntimeError(f"{data.get('code')}: {data.get('error')}")
return data
solution = solve("https://freelancehunt.com/projects")
site = httpx.Client(
proxy=PROXY, # тот же прокси
headers={"User-Agent": solution["user_agent"]}, # тот же UA
cookies={c["name"]: c["value"] for c in solution["cookies"]},
)
page = site.get("https://freelancehunt.com/projects")
if page.status_code == 403:
solution = solve("https://freelancehunt.com/projects") # та же сессия, обновить cf_clearance
```
+6
View File
@@ -0,0 +1,6 @@
API_PORT=
WORKER_CPUS=1
WORKER_MEM_LIMIT=1g
BROWSER_SLOTS=1
RABBITMQ_PASSWORD=
POSTGRES_PASSWORD=
+18
View File
@@ -1 +1,19 @@
# Project Env
# порт api на хосте (docker-compose)
API_PORT=8000
# CPU и память контейнера worker
WORKER_CPUS=4
WORKER_MEM_LIMIT=8g
# сколько окон Chrome на воркер — и выполняющих задачу, и удерживаемых (browser_idle_timeout); ≈ числу ядер
BROWSER_SLOTS=4
# пароли RabbitMQ и Postgres (сеть compose наружу не открыта)
RABBITMQ_PASSWORD=robotex
POSTGRES_PASSWORD=robotex
# сколько часов хранится результат задачи в Redis
RESULT_TTL_HOURS=24
# через сколько дней без использования удаляется профиль браузера
PROFILE_TTL_DAYS=7
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503
V1_QUEUE_WAIT=30
NO_SLOTS_RETRY_AFTER=5
LOG_LEVEL=INFO
+6
View File
@@ -0,0 +1,6 @@
[pytest]
pythonpath = src client/src
testpaths = tests
addopts = -m "not browser"
markers =
browser: настоящий Chrome и сеть, запускать в Docker: make test-browser
+20
View File
@@ -0,0 +1,20 @@
# зависимости образа api (Dockerfile.api): без Chrome и Patchright. Версии — как в requirements.txt
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
click==8.5.0
dramatiq==2.2.1
fastapi==0.141.1
h11==0.16.0
idna==3.19
pika==1.4.4
pydantic==2.13.5
pydantic_core==2.46.5
redis==8.1.0
starlette==1.6.0
typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.52.4
SQLAlchemy==2.1.4
asyncpg==0.32.0
greenlet==3.5.5
+8
View File
@@ -0,0 +1,8 @@
# зависимости для тестов, в прод-образ не попадают: make sync-dev
pytest==9.1.1
pluggy==1.6.0
iniconfig==2.3.1
packaging==26.3
fakeredis==2.39.0
sortedcontainers==2.4.0
aiosqlite==0.22.1
+5 -1
View File
@@ -7,6 +7,7 @@ annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
apify_fingerprint_datapoints==0.15.0
asyncpg==0.32.0
attrs==26.1.0
blessed==1.49.0
browserforge==1.2.4
@@ -15,6 +16,7 @@ certifi==2026.7.22
charset-normalizer==3.5.1
click==8.5.0
distro==1.9.0
dramatiq==2.2.1
editor==1.8.0
fastapi==0.141.1
frozenlist==1.8.0
@@ -47,6 +49,7 @@ multidict==6.7.1
numpy==2.5.3
orjson==3.12.0
patchright==1.63.0
pika==1.4.4
platformdirs==4.11.7
playwright==1.62.0
propcache==0.5.2
@@ -69,9 +72,10 @@ runs==1.3.0
screeninfo==0.8.1
shiboken6==6.11.2
six==1.17.0
SQLAlchemy==2.1.4
starlette==1.6.0
typing-inspection==0.4.4
typing_extensions==4.16.0
typing-inspection==0.4.4
ua-parser==1.0.2
ua-parser-builtins==202606
urllib3==2.7.0
+41 -26
View File
@@ -85,7 +85,6 @@ async def move_mouse_human(
if i < steps:
px += random.uniform(-1.5, 1.5)
py += random.uniform(-1.5, 1.5)
print("Move:", px, py)
await page.mouse.move(px, py)
await page.wait_for_timeout(step_delay_ms * random.uniform(0.7, 1.3))
@@ -103,13 +102,9 @@ async def click_human(
"""move_mouse_human + клик с человеческой задержкой между наведением и down/up, вместо
мгновенного page.mouse.click(x, y)."""
await move_mouse_human(page, x, y, **move_kwargs)
print('wait for timeout')
await page.wait_for_timeout(random.uniform(50, 180))
print('key down')
await page.mouse.down(button=button)
print('sleep')
await page.wait_for_timeout(random.uniform(40, 99))
print('key up')
await page.mouse.up(button=button)
@@ -129,28 +124,48 @@ async def wait_for_page_stability(page: Page, load_dom: bool, network_idle: bool
await wait_for_networkidle(page)
async def typing(page: Page, locator: str, text: str):
element = page.locator(locator)
def _key_delay_ms(char: str) -> float:
"""Пауза после символа: строчные латинские набираются быстрее всего, остальное — медленнее."""
if char in string.ascii_lowercase:
delays = (50, 100)
elif char in string.ascii_uppercase or char in string.digits:
delays = (100, 150)
elif char in string.punctuation:
delays = (100, 200)
else:
# кириллица и прочие символы, пробел
delays = (60, 160)
return random.uniform(*delays)
async def typing(page: Page, locator: str, text: str) -> None:
"""Ввод как у человека: курсор подводится к полю по кривой, клик в случайную точку поля,
старое значение стирается, текст набирается посимвольно с паузами."""
element = page.locator(locator).first
await element.scroll_into_view_if_needed()
assert await element.is_visible()
assert await element.is_enabled()
assert await element.is_editable()
await element.focus()
await element.click()
if not await element.is_visible():
raise ValueError(f"element is not visible: {locator}")
if not await element.is_editable():
raise ValueError(f"element is not editable: {locator}")
box = await element.bounding_box()
if box is None:
raise ValueError(f"element has no box: {locator}")
await click_human(
page,
box["x"] + box["width"] * random.uniform(0.2, 0.8),
box["y"] + box["height"] * random.uniform(0.3, 0.7),
)
if await element.input_value():
await page.keyboard.press("ControlOrMeta+A")
await page.wait_for_timeout(random.uniform(80, 200))
await page.keyboard.press("Backspace")
await page.wait_for_timeout(random.uniform(100, 250))
for char in text:
await page.keyboard.press(char)
if char in string.ascii_lowercase:
delays = (50, 100)
elif char in string.ascii_uppercase:
delays = (100, 150)
elif char in string.digits:
delays = (100, 150)
elif char in string.punctuation:
delays = (100, 200)
else:
raise SystemError("character not supported: {}".format(char))
await page.wait_for_timeout(random.uniform(*delays))
# keyboard.type даёт keydown/keypress/keyup для клавиш раскладки и insertText для остальных
# символов (кириллица и т.п.); press() на них падал
await page.keyboard.type(char)
await page.wait_for_timeout(_key_delay_ms(char))
+13 -9
View File
@@ -72,8 +72,6 @@ from ..registry import register
from ..humanize import wait_for_page_stability, wait_for_networkidle, click_human
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler())
class CloudflareDetector(Detector):
@@ -185,18 +183,24 @@ __CF_PATTERN__ = re.compile(r"^https://challenges\.cloudflare\.com/cdn-cgi/chall
class CloudflareSolver(Solver):
# опрос исчезновения маркера челленджа после клика / на interstitial
POLL_INTERVAL_MS = 250
WAIT_CLEARED_S = 10
async def solve(self, page: Page, info: ChallengeInfo):
print("start solving")
log.debug("start solving")
iframe = page.frame(url=__CF_PATTERN__)
if iframe:
await page.wait_for_timeout(randint(500, 1000))
frame_el = await iframe.frame_element()
box = await frame_el.bounding_box()
else:
box = await page.query_selector("xpath=//input[@name='cf-turnstile-response']/../div").bounding_box()
container = await page.query_selector("xpath=//input[@name='cf-turnstile-response']/../div")
box = await container.bounding_box() if container else None
if info.stage == Stage.CHECKBOX:
if box is None:
raise RuntimeError("turnstile widget not found on the page")
# # Calculate the Captcha coordinates for any viewport
captcha_x, captcha_y = box["x"] + randint(26, 28), box["y"] + randint(25, 27)
@@ -212,11 +216,11 @@ class CloudflareSolver(Solver):
finally:
return
else:
deadline = time.time() + 10
deadline = time.time() + self.WAIT_CLEARED_S
while time.time() < deadline:
if not await page.query_selector(info.detect_locator):
return
await page.wait_for_timeout(1)
await page.wait_for_timeout(self.POLL_INTERVAL_MS)
return
elif info.stage == Stage.INTERSTITIAL:
if info.success_locator:
@@ -227,11 +231,11 @@ class CloudflareSolver(Solver):
else:
return
else:
deadline = time.time() + 10
deadline = time.time() + self.WAIT_CLEARED_S
while time.time() < deadline:
if not await page.query_selector(info.detect_locator):
return
await page.wait_for_timeout(1)
await page.wait_for_timeout(self.POLL_INTERVAL_MS)
return
else:
@@ -257,7 +261,7 @@ class CloudflareSolver(Solver):
@register
class ReCaptchaProvider(AntiBotProvider):
class CloudflareProvider(AntiBotProvider):
provider = 'cloudflare'
_solver_cls = CloudflareSolver
_detector_cls = CloudflareDetector
+3 -4
View File
@@ -11,8 +11,6 @@ from ..registry import register
from ..exceptions import IframeLoadError
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler())
JS_CHALLENGE_SUBMIT = """(token) => {
@@ -36,6 +34,8 @@ JS_CHALLENGE_SUBMIT = """(token) => {
class ReCaptchaSolver(Solver):
page: Page
# пауза на стадии INTERSTITIAL перед повторным detect
INTERSTITIAL_WAIT_MS = 15_000
def __init__(self, captcha_solver: Captcha, **kwargs) -> None:
super().__init__(captcha_solver=captcha_solver, **kwargs)
@@ -49,8 +49,7 @@ class ReCaptchaSolver(Solver):
self.page = page
log.debug("Начинаю решение recaptcha антибот преграды")
if info.stage == Stage.INTERSTITIAL:
# TODO: вытащить
await page.wait_for_timeout(15)
await page.wait_for_timeout(self.INTERSTITIAL_WAIT_MS)
return
elif info.stage == Stage.CHECKBOX:
log.debug("Нужно тыкнуть чекбокс")
+20 -5
View File
@@ -3,9 +3,10 @@
"""
import logging
import time
import traceback
from dataclasses import dataclass
from patchright.async_api import Error as PlaywrightError
from captcha import load_captcha
from .base import Stage
@@ -13,8 +14,8 @@ from .registry import select_one
from .exceptions import ProviderNotDetected
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler())
SOLVE_RETRY_DELAY_MS = 1000
@dataclass
@@ -31,6 +32,10 @@ async def pass_challenges(
success_locator: str | None = None,
detect_locator: str | None = None,
timeout_ms=120_000) -> ChallengeOutcome:
if not antibot_name:
# провайдер не указан — антибот не проходим, сразу к действиям
return ChallengeOutcome(True, reason="antibot not requested")
# установка дедлайна
deadline = time.monotonic() + timeout_ms / 1000
@@ -49,7 +54,14 @@ async def pass_challenges(
info = None
while time.monotonic() < deadline:
info = await provider.detect(page) # noqa
try:
info = await provider.detect(page) # noqa
except PlaywrightError as e:
# челлендж сам переходит на целевую страницу посреди detect()
# («Execution context was destroyed») — даём навигации закончиться и смотрим заново
log.debug("%s: detect interrupted (%s), retrying", provider.provider, e.message.splitlines()[0])
await page.wait_for_load_state("load")
continue
# 3. проверяем что решение успешно
if info.success: # noqa
return ChallengeOutcome(True, reason=(info.meta or {}).get("reason"))
@@ -57,7 +69,10 @@ async def pass_challenges(
try:
await provider.solve(page, info)
except Exception as e:
traceback.print_exc()
log.warning("%s: solve failed (%s), retrying", provider.provider, str(e).splitlines()[0] if str(e) else type(e).__name__)
log.debug("solve traceback", exc_info=True)
# пауза, иначе при стабильно падающем solve цикл крутится вхолостую до дедлайна
await page.wait_for_timeout(SOLVE_RETRY_DELAY_MS)
continue
else:
return ChallengeOutcome(
+2 -1
View File
@@ -29,4 +29,5 @@ async def select_one(page: Page, provider_name: str, **kwargs) -> AntiBotProvide
provider.configure(**kwargs)
return provider
else:
raise ProviderNotDetected()
known = ", ".join(p.provider for p in _detectors)
raise ProviderNotDetected(f"unknown captcha_type {provider_name!r}, expected one of: {known}")
+48 -27
View File
@@ -1,8 +1,10 @@
import asyncio
import re
import time
from patchright.async_api import Page
from antibot.base import click_and_race
from antibot.humanize import typing as human_typing
from antibot.orchestrator import pass_challenges
from captcha import load_captcha
from api.schemas import ActionRequest
@@ -10,50 +12,58 @@ from api.schemas import ActionRequest
_actions: dict = {}
class ChallengeFailed(Exception):
pass
def register(fun) -> None:
_actions[fun.__name__] = fun
return fun
async def _query(page: Page, selector: str):
element = await page.query_selector(selector)
if element is None:
raise LookupError(f"selector not found: {selector}")
return element
@register
async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
assert act.value
await page.fill(act.selector, act.value)
@register
async def typing(page: Page, act: ActionRequest, **kwargs) -> str | None:
"""В отличие от fill — как человек: подвод мыши, клик, посимвольный ввод с паузами (#14)."""
await human_typing(page, act.selector, act.value)
@register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
assert act.selector
assert act.value
assert captcha_key
captcha_solver = load_captcha(captcha_key, act.value)
await captcha_solver.solve(act.selector, page)
@register
async def delay(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.value
await asyncio.sleep(int(act.value))
@register
async def mouse_move(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = await page.query_selector(act.selector)
element = await _query(page, act.selector)
await element.hover(scroll="auto")
@register
async def click(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector)
await element.click()
@register
async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector)
async with page.expect_event(
@@ -65,19 +75,20 @@ async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = await page.query_selector(act.selector)
element = await _query(page, act.selector)
return await element.inner_html()
@register
async def inner_text(page: Page, act: ActionRequest, **kwargs) -> str | None:
html_source = await inner_html(page, act, **kwargs)
text = re.sub(r"<[^>]+>", "", html_source)
return text
@register
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
assert 1 <= act.timeout <= 7200
try:
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
except Exception as e:
return str(e)
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
@register
@@ -92,6 +103,8 @@ async def pass_challenge(
success_locator=act.selector or success_locator,
detect_locator=detect_locator
)
if not result.cleared:
raise Exception(f"challenge not passed: {result.reason}")
return result.reason
@@ -100,7 +113,10 @@ async def execute(
captcha_key: str | None = None, antibot_name: str | None = None,
success_locator: str | None = None,
detect_locator: str | None = None) -> list[dict]:
"""Выполнение очереди автоматизации"""
"""Выполнение очереди автоматизации.
Если антибот не пройден — бросает ChallengeFailed, действия не выполняются: на странице-заглушке
они бы упали с невнятной ошибкой селектора."""
actions_result = []
ctx = {
"captcha_key": captcha_key,
@@ -108,27 +124,32 @@ async def execute(
"success_locator": success_locator,
"detect_locator": detect_locator,
}
await pass_challenges(
outcome = await pass_challenges(
antibot_name, page,
captcha=captcha_key,
success_locator=success_locator,
detect_locator=detect_locator,
)
if not outcome.cleared:
stage = outcome.last_stage.value if outcome.last_stage else None
raise ChallengeFailed(f"{antibot_name} not passed: {outcome.reason} (stage: {stage})")
for act in actions:
new_line = {
"type": act.type,
}
started = time.monotonic()
try:
# TODO: добавить время выполнения
result = await _actions[act.type](page, act, **ctx)
except Exception as e:
new_line["ok"] = False
new_line["result"] = str(e)
actions_result.append(new_line)
break
new_line["result"] = str(e) or type(e).__name__
else:
new_line["ok"] = True
if result:
new_line["result"] = result
actions_result.append(new_line)
return actions_result if len(actions_result) else [{"status": "pass"}]
new_line["duration_ms"] = int((time.monotonic() - started) * 1000)
actions_result.append(new_line)
if not new_line["ok"]:
break
return actions_result
+71
View File
@@ -0,0 +1,71 @@
// Шум canvas профиля (#89): младший бит одного канала у ~1/64 непрозрачных пикселей.
// Детерминирован от seed профиля и номера пикселя: внутри профиля хеш canvas стабилен,
// между профилями — разный. Пустые (прозрачные) пиксели не меняются, иначе пустой canvas
// перестал бы быть пустым. Подменённые функции выглядят нативными для toString.
(() => {
const SEED = __SEED__ | 0;
const MAX_PIXELS = 4096 * 4096;
const fnToString = Function.prototype.toString;
const nativeSource = new WeakMap();
const noise = (data) => {
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] === 0) continue;
let x = Math.imul((i >>> 2) ^ SEED, 0x9e3779b1);
x ^= x >>> 15;
x = Math.imul(x, 0x85ebca77);
x ^= x >>> 13;
if ((x & 63) === 0) {
const c = i + ((x >>> 6) % 3);
data[c] ^= 1;
}
}
};
const replace = (proto, name, make) => {
const desc = Object.getOwnPropertyDescriptor(proto, name);
if (!desc || typeof desc.value !== 'function') return null;
const orig = desc.value;
const impl = make(orig);
const fn = { [name](...args) { return impl.call(this, args); } }[name];
Object.defineProperty(fn, 'length', { value: orig.length });
nativeSource.set(fn, fnToString.call(orig));
Object.defineProperty(proto, name, { ...desc, value: fn });
return orig;
};
const toStringDesc = Object.getOwnPropertyDescriptor(Function.prototype, 'toString');
const toString = { toString() { return nativeSource.has(this) ? nativeSource.get(this) : fnToString.call(this); } }.toString;
nativeSource.set(toString, fnToString.call(fnToString));
Object.defineProperty(Function.prototype, 'toString', { ...toStringDesc, value: toString });
const ctxProto = CanvasRenderingContext2D.prototype;
const origGetImageData = replace(ctxProto, 'getImageData', (orig) => function (args) {
const img = orig.apply(this, args);
noise(img.data);
return img;
});
// toDataURL/toBlob: шумим копию, исходный canvas страницы не трогаем
const noisyCopy = (canvas) => {
const w = canvas.width, h = canvas.height;
if (!w || !h || w * h > MAX_PIXELS) return canvas;
const copy = document.createElement('canvas');
copy.width = w;
copy.height = h;
const ctx = copy.getContext('2d');
ctx.drawImage(canvas, 0, 0);
const img = origGetImageData.call(ctx, 0, 0, w, h);
noise(img.data);
ctx.putImageData(img, 0, 0);
return copy;
};
const canvasProto = HTMLCanvasElement.prototype;
replace(canvasProto, 'toDataURL', (orig) => function (args) {
return orig.apply(noisyCopy(this), args);
});
replace(canvasProto, 'toBlob', (orig) => function (args) {
return orig.apply(noisyCopy(this), args);
});
})();
+41
View File
@@ -0,0 +1,41 @@
"""Запросы и ответы для лога без секретов клиента: креды прокси, ключ 2captcha, вводимые значения,
значения cookies, html и скриншот."""
import json
from urllib.parse import urlsplit
from api.schemas import SolveRequest
def mask_proxy(proxy_url: str | None) -> str | None:
if not proxy_url:
return proxy_url
parts = urlsplit(proxy_url)
return f"{parts.scheme}://{'***@' if parts.username else ''}{parts.hostname}:{parts.port}"
def safe_request_data(payload: SolveRequest) -> dict:
"""Запрос без секретов: для лога и журнала запросов (#17)."""
data = payload.model_dump(exclude_none=True)
data["proxy"] = mask_proxy(payload.proxy)
if payload.captcha:
data["captcha"] = "***"
if payload.cookies:
data["cookies"] = [c.get("name") for c in payload.cookies]
for act in data["actions"]:
if act["type"] in ("fill", "typing") and "value" in act:
act["value"] = "***"
return data
def safe_request_dump(payload: SolveRequest) -> str:
return json.dumps(safe_request_data(payload), indent=2, ensure_ascii=False)
def safe_response_dump(result: dict) -> str:
data = dict(result)
if data.get("cookies"):
data["cookies"] = [c.get("name") for c in data["cookies"]]
for key in ("html", "screenshot"):
if data.get(key):
data[key] = f"<{len(data[key])} chars>"
return json.dumps(data, indent=2, ensure_ascii=False)
+83
View File
@@ -0,0 +1,83 @@
"""
Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas.
Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет
монитор между визитами.
Экран и DPR применяются нативной эмуляцией Chrome. JS — только минимальный шум canvas (js/canvas_noise.js,
#89): полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
Остальное (WebGL, hardwareConcurrency, шрифты) — родное, как его отдаёт Chrome через Patchright.
User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с
navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда
родной UA установленного Chrome, клиент берёт его из ответа.
"""
import json
import logging
import random
from pathlib import Path
from browserforge.fingerprints import FingerprintGenerator, Screen
log = logging.getLogger(__name__)
SETTINGS_FILE = "robotex_session.json"
# окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна
MIN_SCREEN = (1920, 1080)
CANVAS_NOISE_JS = (Path(__file__).parent / "js" / "canvas_noise.js").read_text()
def new_canvas_seed() -> int:
return random.randint(1, 2**31 - 1)
def generate_fingerprint(attempts: int = 10) -> dict:
"""Правдоподобный отпечаток десктопного Chrome на Linux из датасета browserforge."""
screen, dpr = {"width": MIN_SCREEN[0], "height": MIN_SCREEN[1]}, 1
generator = FingerprintGenerator()
for _ in range(attempts):
# strict=True: без него browserforge при отсутствии точного совпадения тихо ослабляет
# нижнюю границу экрана и может отдать экран меньше окна браузера; цена — изредка
# ValueError, тогда просто пробуем ещё раз
try:
fp = generator.generate(
browser=["chrome"], os=["linux"], device="desktop", strict=True,
screen=Screen(min_width=MIN_SCREEN[0], min_height=MIN_SCREEN[1]),
)
except ValueError:
continue
screen, dpr = {"width": fp.screen.width, "height": fp.screen.height}, fp.screen.devicePixelRatio
break
else:
log.warning("browserforge failed %d times, using default screen", attempts)
return {"screen": screen, "device_scale_factor": dpr, "canvas_seed": new_canvas_seed()}
def load(user_data_dir: Path) -> dict:
try:
return json.loads((user_data_dir / SETTINGS_FILE).read_text())
except (OSError, json.JSONDecodeError):
return {}
def load_or_create(user_data_dir: Path) -> dict:
"""Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии."""
settings = load(user_data_dir)
if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]:
return settings
if "fingerprint" in settings:
# профиль до #89: экран сохраняем, добавляем только seed шума
settings = {"fingerprint": settings["fingerprint"] | {"canvas_seed": new_canvas_seed()}}
else:
settings = {"fingerprint": generate_fingerprint()}
(user_data_dir / SETTINGS_FILE).write_text(json.dumps(settings, ensure_ascii=False))
return settings
def browser_kwargs(settings: dict) -> dict:
"""kwargs AsyncStealthySession под отпечаток профиля."""
fp = settings["fingerprint"]
return {
"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]},
"init_script_content": CANVAS_NOISE_JS.replace("__SEED__", str(int(fp["canvas_seed"]))),
}
+138 -5
View File
@@ -1,34 +1,82 @@
from typing import Any
from typing import Any, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator, model_validator
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
# максимум browser_idle_timeout в минутах: открытое окно держит слот браузера (#78)
MAX_BROWSER_IDLE_TIMEOUT = 120
PROXY_SCHEMES = ("http", "https", "socks5")
# тип действия -> обязательные поля (кроме type)
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"fill": ("selector", "value"),
"typing": ("selector", "value"),
"captcha": ("selector", "value"),
"delay": ("value",),
"mouse_move": ("selector",),
"click": ("selector",),
"submit": ("selector",),
"inner_html": ("selector",),
"inner_text": ("selector",),
"wait_for": ("selector",),
"pass_challenge": (),
}
WAIT_FOR_DEFAULT_TIMEOUT = 30
class ActionRequest(BaseModel):
"""
Действие автоматизации. Характеризуется типом и производится над селектором.
Набор параметров зависит от типа, этот класс перечисляет все параметры.
Обязательные поля для каждого типа проверяются здесь же — неверное действие
отклоняется с 422 до запуска браузера.
"""
type: str
selector: str | None = None
value: str | None = None
timeout: int | None = None
@model_validator(mode="after")
def check_fields(self):
if self.type not in _REQUIRED_FIELDS:
raise ValueError(f"unknown action type {self.type!r}, expected one of: {', '.join(_REQUIRED_FIELDS)}")
missing = [f for f in _REQUIRED_FIELDS[self.type] if getattr(self, f) in (None, "")]
if missing:
raise ValueError(f"action {self.type!r} requires: {', '.join(missing)}")
if self.type == "wait_for":
if self.timeout is None:
self.timeout = WAIT_FOR_DEFAULT_TIMEOUT
if not 1 <= self.timeout <= 7200:
raise ValueError("wait_for timeout must be between 1 and 7200 seconds")
elif self.type == "delay":
try:
delay = int(self.value)
except ValueError:
raise ValueError("delay value must be an integer number of seconds") from None
if not 0 <= delay <= 600:
raise ValueError("delay value must be between 0 and 600 seconds")
elif self.type == "captcha" and self.value not in CAPTCHA_TYPES:
raise ValueError(f"captcha value must be one of: {', '.join(CAPTCHA_TYPES)}")
return self
class SolveRequest(BaseModel):
"""
Объект запроса для автоматизации на сайте.
"""
url: str
proxy: str
proxy: str | None = None
timeout: int | None = None
user_agent: str | None = None
cookies: list[dict[str, Any]] | None = None
# List[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"], partitionKey: Union[str, None]}]
actions: list[ActionRequest]
html: bool = False
# TODO: screenshot: bool = False
screenshot: bool = False
captcha: str | None = None
captcha_detect_locator: str | None = None
@@ -43,3 +91,88 @@ class SolveRequest(BaseModel):
retry_delay: float = 1.0
wait: int = 0
google_search: bool = False
@model_validator(mode="before")
@classmethod
def reject_browser_identity(cls, data):
# UA и размер окна задаёт сам браузер: подмена расходится с userAgentData и движком,
# и Cloudflare такой браузер не пропускает (см. docs/Usage.md)
if isinstance(data, dict) and "user_agent" in data:
raise ValueError(
"user_agent is not supported: take user_agent from the response instead (see docs/Usage.md)"
)
return data
@field_validator("proxy")
@classmethod
def check_proxy(cls, proxy: str | None) -> str | None:
if not proxy:
return None
try:
parts = urlsplit(proxy)
port = parts.port
except ValueError as e:
raise ValueError(f"invalid proxy url: {e}") from None
if parts.scheme not in PROXY_SCHEMES:
raise ValueError(f"proxy scheme must be one of: {', '.join(PROXY_SCHEMES)}")
if not parts.hostname or not port:
raise ValueError("proxy must be in form scheme://[user:pass@]host:port")
return proxy
@model_validator(mode="after")
def check_captcha_key(self):
if not self.captcha and any(a.type == "captcha" for a in self.actions):
raise ValueError("action 'captcha' requires request field 'captcha' (2captcha key)")
return self
class ActionResult(BaseModel):
type: str
ok: bool
result: str | None = None
duration_ms: int
class SolveResponse(BaseModel):
"""
Ответ /v1/solve. status — "ok" (антибот пройден, все действия выполнены) или "err".
code — машиночитаемая причина ошибки, error — текст для человека.
"""
status: Literal["ok", "err"]
code: Literal["no_slots", "request_failed", "challenge_failed", "action_failed"] | None = None
error: str | None = None
user_agent: str | None = None
cookies: list[dict[str, Any]] | None = None
actions: list[ActionResult] = []
html: str | None = None
screenshot: str | None = None
duration_ms: int | None = None
class TaskRequest(SolveRequest):
"""
Задача POST /v2/tasks: тело как у /v1/solve плюс удержание окна браузера.
url необязателен, если окно сессии открыто: тогда действия выполняются на текущей странице.
"""
url: str | None = None
# минуты простоя, через которые окно сессии закроется; 0 — закрыть сразу после задачи
browser_idle_timeout: int = Field(0, ge=0, le=MAX_BROWSER_IDLE_TIMEOUT)
class TaskCreated(BaseModel):
task_id: str
status: str
url: str
class TaskResponse(BaseModel):
"""
GET /v2/tasks/{id}. status: queued → running → done | failed.
done — результат в result (в нём свой status ok/err, как в /v1/solve);
failed — задачу не удалось выполнить (воркер упал), причина в error.
"""
task_id: str
status: Literal["queued", "running", "done", "failed", "cancelled"]
error: str | None = None
result: SolveResponse | None = None
-39
View File
@@ -1,48 +1,9 @@
import asyncio
import json
import hashlib
import time
from pathlib import Path
from patchright.async_api import Page
from twocaptcha import TwoCaptcha
def cached_persistent(file_name=None):
def key(*args, **kwargs):
return hashlib.sha256(f'{str(args)}-{str(kwargs)}'.encode()).hexdigest()
def wrapper(fun):
def clear_cache():
cache_file.write_text(json.dumps({}))
cache_file = Path(file_name)
if not cache_file.exists():
cache_file.write_text(json.dumps({}))
async def wrapped(*args, **kwargs):
data = json.loads(cache_file.read_text())
_key = key(fun.__name__, *args, **kwargs)
if cached_value := data.get(_key):
cached_value, cache_time = cached_value
if time.time() - cache_time > 300:
cached_value = None
value = cached_value or await fun(*args, **kwargs)
data[_key] = [value, time.time()]
cache_file.write_text(json.dumps(data))
return value
setattr(wrapped, '_clear_cache', clear_cache)
return wrapped
return wrapper
class Captcha:
ctype = "captcha"
-2
View File
@@ -16,8 +16,6 @@ import logging
from .base import Captcha, Page
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverHCaptcha(Captcha):
-2
View File
@@ -7,8 +7,6 @@ from urllib.parse import urlparse, parse_qs
from .base import Captcha, Page
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverReCaptcha(Captcha):
-2
View File
@@ -16,8 +16,6 @@ import logging
from .base import Captcha, Page
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverTurnstile(Captcha):
+6
View File
@@ -0,0 +1,6 @@
"""
Postgres (#4, #17): клиенты с API-ключами и журнал работы — окна браузера и запросы.
Общий для api и worker. Схема создаётся при старте сервиса (`init_db`). `DATABASE_URL` —
`postgresql+asyncpg://...`; в тестах — `sqlite+aiosqlite://...`.
"""
+67
View File
@@ -0,0 +1,67 @@
"""
Клиенты и API-ключи:
python -m db.cli create <name> # выпустить ключ: печатается один раз
python -m db.cli list
python -m db.cli revoke <id>
python -m db.cli stats [--days 7] # запросы, их время и время работы браузера по клиентам
В compose — через make: make key-create name=..., make key-list, make key-revoke id=..., make stats days=...
"""
import argparse
import asyncio
from db import clients, stats
from db.engine import connect
async def run(args) -> None:
db = connect()
try:
await db.init()
if args.command == "create":
client, key = await clients.create_client(db, args.name)
print(f"client {client.id} ({client.name}), API key — сохраните, больше он не показывается:\n{key}")
elif args.command == "list":
for c in await clients.list_clients(db):
state = f"revoked {c.revoked_at:%Y-%m-%d %H:%M}" if c.revoked_at else "active"
last = f"{c.last_access_at:%Y-%m-%d %H:%M}" if c.last_access_at else "never"
print(f"{c.id:>4} {c.key_prefix}… {state:<24} last access {last:<16} {c.name}")
elif args.command == "revoke":
print("revoked" if await clients.revoke(db, args.id) else "no active client with this id")
elif args.command == "stats":
print_stats(await stats.usage(db, stats.since_days(args.days)), args.days)
finally:
await db.close()
def _ms(value: int | None) -> str:
return "—" if value is None else f"{value / 1000:.1f}s"
def print_stats(rows: list, days: float) -> None:
print(f"за {days:g} дн.")
print(f"{'клиент':<24} {'запросов':>8} {'ok':>6} {'ср.':>7} {'p95':>7} {'окон':>5} {'браузер, ч':>10} ошибки")
for u in rows:
errors = ", ".join(f"{k} {v}" for k, v in sorted(u.errors.items())) or "—"
name = f"{u.client_id or '-'} {u.name}"[:24]
print(
f"{name:<24} {u.requests:>8} {u.ok:>6} {_ms(u.avg_ms):>7} {_ms(u.p95_ms):>7} "
f"{u.windows:>5} {u.browser_seconds / 3600:>10.2f} {errors}"
)
if not rows:
print("запросов не было")
def main() -> None:
parser = argparse.ArgumentParser(prog="python -m db.cli", description="Клиенты Robotex и их API-ключи")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("create", help="выпустить ключ новому клиенту").add_argument("name")
sub.add_parser("list", help="список клиентов")
sub.add_parser("revoke", help="отозвать ключ").add_argument("id", type=int)
sub.add_parser("stats", help="статистика использования по клиентам").add_argument("--days", type=float, default=7)
asyncio.run(run(parser.parse_args()))
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
"""Клиенты и API-ключи (#4). Ключ показывается один раз при выпуске, в базе — только sha256."""
import hashlib
import secrets
from sqlalchemy import select, update
from db.engine import Database
from db.models import Client, utcnow
KEY_PREFIX = "rbx_"
def hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
async def create_client(db: Database, name: str) -> tuple[Client, str]:
key = KEY_PREFIX + secrets.token_urlsafe(32)
client = Client(name=name, key_hash=hash_key(key), key_prefix=key[:12])
async with db.session() as session, session.begin():
session.add(client)
return client, key
async def authenticate(db: Database, key: str | None) -> Client | None:
"""Клиент по ключу, если ключ не отозван; отмечает время последнего обращения."""
if not key:
return None
async with db.session() as session, session.begin():
client = await session.scalar(
select(Client).where(Client.key_hash == hash_key(key), Client.revoked_at.is_(None))
)
if client:
client.last_access_at = utcnow()
return client
async def revoke(db: Database, client_id: int) -> bool:
async with db.session() as session, session.begin():
result = await session.execute(
update(Client).where(Client.id == client_id, Client.revoked_at.is_(None)).values(revoked_at=utcnow())
)
return result.rowcount > 0
async def list_clients(db: Database) -> list[Client]:
async with db.session() as session:
return list(await session.scalars(select(Client).order_by(Client.id)))
+30
View File
@@ -0,0 +1,30 @@
import os
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from db.models import Base
DEFAULT_URL = "postgresql+asyncpg://robotex:robotex@localhost:5432/robotex"
# api и worker создают схему при старте одновременно — в Postgres через advisory lock
_SCHEMA_LOCK = 780078
class Database:
def __init__(self, engine: AsyncEngine):
self.engine = engine
self.session = async_sessionmaker(engine, expire_on_commit=False)
async def init(self) -> None:
"""Создать недостающие таблицы."""
async with self.engine.begin() as conn:
if conn.dialect.name == "postgresql":
await conn.execute(text(f"SELECT pg_advisory_xact_lock({_SCHEMA_LOCK})"))
await conn.run_sync(Base.metadata.create_all)
async def close(self) -> None:
await self.engine.dispose()
def connect(url: str | None = None) -> Database:
return Database(create_async_engine(url or os.getenv("DATABASE_URL", DEFAULT_URL), pool_pre_ping=True))
+69
View File
@@ -0,0 +1,69 @@
"""
Журнал воркера (#17): окна браузера и задачи. Ошибка записи не роняет задачу — только предупреждение в лог.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import update
from db.engine import Database
from db.models import Request, Window, utcnow
log = logging.getLogger(__name__)
class Journal:
def __init__(self, db: Database | None):
self.db = db
async def _write(self, what: str, fn):
if self.db is None:
return None
try:
async with self.db.session() as session, session.begin():
return await fn(session)
except Exception:
log.warning("journal: %s failed", what, exc_info=True)
return None
async def window_opened(self, client_id: int | None, session_id: str, task_id: str, worker_id: str) -> int | None:
async def write(session):
window = Window(client_id=client_id, session_id=session_id, task_id=task_id, worker_id=worker_id)
session.add(window)
await session.flush()
return window.id
return await self._write("window opened", write)
async def window_used(self, window_id: int | None) -> None:
if window_id is not None:
await self._write("window used", lambda s: s.execute(
update(Window).where(Window.id == window_id).values(last_access_at=utcnow())
))
async def window_closed(self, window_id: int | None) -> None:
if window_id is not None:
await self._write("window closed", lambda s: s.execute(
update(Window).where(Window.id == window_id).values(closed_at=utcnow())
))
async def request_started(
self, task_id: str, client_id: int | None, session_id: str, api: str,
created_at: float | None, task_data: dict,
) -> None:
created = datetime.fromtimestamp(created_at, timezone.utc) if created_at else None
async def write(session):
session.add(Request(
task_id=task_id, client_id=client_id, session_id=session_id, api=api,
created_at=created, task_data=task_data,
))
await self._write("request started", write)
async def request_finished(self, task_id: str, window_id: int | None, result: dict | None) -> None:
"""result — ответ задачи; None — задачу не удалось выполнить (status failed)."""
values = {"finished_at": utcnow(), "window_id": window_id, "status": "failed"}
if result is not None:
values.update(status=result.get("status"), code=result.get("code"), duration_ms=result.get("duration_ms"))
await self._write("request finished", lambda s: s.execute(
update(Request).where(Request.task_id == task_id).values(**values)
))
+69
View File
@@ -0,0 +1,69 @@
"""
Таблицы:
* `clients` — клиенты API («Users» в #17): ключ (только sha256), время создания и последнего обращения.
* `windows` — окна браузера: когда открыто и закрыто, последнее обращение, клиент, задача, открывшая окно.
Из них считается время, которое браузер был активен.
* `requests` — задачи: создание (постановка в очередь), начало и конец выполнения, итог, данные задачи без
секретов (прокси, ключ 2captcha, вводимые значения и значения cookies не пишутся).
"""
from datetime import datetime, timezone
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
type_annotation_map = {datetime: DateTime(timezone=True)}
class Client(Base):
__tablename__ = "clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200))
# sha256 ключа: сам ключ показывается один раз при выпуске и нигде не хранится
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
# начало ключа — чтобы отличать ключи в списке
key_prefix: Mapped[str] = mapped_column(String(16))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
last_access_at: Mapped[datetime | None]
revoked_at: Mapped[datetime | None]
class Window(Base):
__tablename__ = "windows"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
session_id: Mapped[str] = mapped_column(String(36), index=True)
# задача, открывшая окно
task_id: Mapped[str] = mapped_column(String(36))
worker_id: Mapped[str] = mapped_column(String(100))
started_at: Mapped[datetime] = mapped_column(default=utcnow)
last_access_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None]
class Request(Base):
__tablename__ = "requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[str] = mapped_column(String(36), unique=True)
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
session_id: Mapped[str] = mapped_column(String(36), index=True)
window_id: Mapped[int | None] = mapped_column(ForeignKey("windows.id"))
# api: /v1/solve или /v2/tasks
api: Mapped[str] = mapped_column(String(8))
created_at: Mapped[datetime | None]
started_at: Mapped[datetime] = mapped_column(default=utcnow, index=True)
finished_at: Mapped[datetime | None]
duration_ms: Mapped[int | None]
# ok / err / failed; code — как в ответе (challenge_failed, action_failed, ...)
status: Mapped[str | None] = mapped_column(String(16))
code: Mapped[str | None] = mapped_column(String(32))
task_data: Mapped[dict] = mapped_column(JSON)
+77
View File
@@ -0,0 +1,77 @@
"""Статистика использования API (#17): число запросов, их время и время, которое был открыт браузер."""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from sqlalchemy import select
from db.engine import Database
from db.models import Client, Request, Window, utcnow
@dataclass
class ClientUsage:
client_id: int | None
name: str
requests: int = 0
ok: int = 0
# по code: challenge_failed, action_failed, request_failed; failed — задачу не выполнили
errors: dict[str, int] = field(default_factory=dict)
durations_ms: list[int] = field(default_factory=list)
windows: int = 0
browser_seconds: float = 0.0
@property
def avg_ms(self) -> int | None:
return int(sum(self.durations_ms) / len(self.durations_ms)) if self.durations_ms else None
@property
def p95_ms(self) -> int | None:
if not self.durations_ms:
return None
ordered = sorted(self.durations_ms)
return ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
def _aware(dt: datetime) -> datetime:
# sqlite отдаёт время без зоны
return dt if dt.tzinfo else dt.replace(tzinfo=utcnow().tzinfo)
async def usage(db: Database, since: datetime) -> list[ClientUsage]:
"""По клиентам за период: запросы, начатые после since, и время окон в пределах периода."""
now = utcnow()
async with db.session() as session:
names = {c.id: c.name for c in await session.scalars(select(Client))}
requests = list(await session.scalars(select(Request).where(Request.started_at >= since)))
windows = list(await session.scalars(
select(Window).where((Window.closed_at.is_(None)) | (Window.closed_at >= since))
))
result: dict[int | None, ClientUsage] = {}
def entry(client_id):
return result.setdefault(client_id, ClientUsage(client_id, names.get(client_id, "—")))
for r in requests:
u = entry(r.client_id)
u.requests += 1
if r.status == "ok":
u.ok += 1
elif r.finished_at is not None:
key = r.code or r.status or "unknown"
u.errors[key] = u.errors.get(key, 0) + 1
if r.duration_ms is not None:
u.durations_ms.append(r.duration_ms)
for w in windows:
u = entry(w.client_id)
u.windows += 1
start = max(_aware(w.started_at), since)
end = _aware(w.closed_at) if w.closed_at else now
u.browser_seconds += max(0.0, (end - start).total_seconds())
return sorted(result.values(), key=lambda u: -u.requests)
def since_days(days: float) -> datetime:
return utcnow() - timedelta(days=days)
+2
View File
@@ -108,6 +108,8 @@ class AsyncSession:
if config.init_script_content:
await ctx.add_init_script(config.init_script_content)
if config.clear_cookies:
await ctx.clear_cookies()
if config.cookies: # pragma: no cover
await ctx.add_cookies(config.cookies)
+1
View File
@@ -28,6 +28,7 @@ class StealthSession(TypedDict, total=False):
wait_selector: Optional[str]
wait_selector_state: SelectorWaitStates
cookies: Sequence[SetCookieParam] | None
clear_cookies: bool
google_search: bool
wait: int | float
timezone_id: str | None
+20
View File
@@ -196,3 +196,23 @@ class AsyncStealthySession(AsyncSession, StealthySessionMixin):
raise
raise RuntimeError("Request failed") # pragma: no cover
async def run(self, page_action, **kwargs: Unpack[StealthFetchParams]) -> Any:
"""Выполнить `page_action` на текущей странице сессии, без навигации — для окна, удерживаемого
между задачами (#82). Страница после `fetch()` остаётся в пуле открытой, её и берём.
:param page_action: функция от `page`; её результат возвращается.
:param timeout / disable_resources / blocked_domains / extra_headers: как у `fetch()`.
"""
params = _validate(kwargs, self, StealthConfig)
if not self._is_alive: # pragma: no cover
raise RuntimeError("Context manager has been closed")
async with self._page_generator(
params.timeout, params.extra_headers, params.disable_resources, None, params.blocked_domains
) as page_info:
try:
return await page_action(page_info.page)
except Exception:
page_info.mark_error()
raise
+1
View File
@@ -73,6 +73,7 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True):
wait_selector: Optional[str] = None
wait_selector_state: SelectorWaitStates = "attached"
cookies: Sequence[SetCookieParam] | None = []
clear_cookies: bool = False # удалить cookies профиля перед установкой `cookies`
google_search: bool = True
wait: Seconds = 0
timezone_id: str | None = ""
+178 -139
View File
@@ -1,163 +1,202 @@
import json
"""
Сервис api (#78): регистрирует задачи в очереди RabbitMQ и отдаёт результаты из Redis. Chrome здесь
нет — задачи выполняет сервис worker (src/worker).
* POST /v2/tasks, GET /v2/tasks/{id}, DELETE /v2/sessions/current — асинхронный API (#80, #82).
* POST /v1/solve — прежний синхронный контракт поверх той же очереди (#84).
Все эндпоинты, кроме /health, — только с ключом клиента в X-API-Key (#4); ключи — в Postgres (db.cli).
Задача и окно сессии видны только клиенту, который их создал.
"""
import asyncio
import logging
import traceback
import os
import time
import uuid
from pathlib import Path
from contextlib import asynccontextmanager
from typing import Annotated
from urllib.parse import urlsplit
from fastapi import Cookie
from fastapi import FastAPI
from fastapi import Request
from fastapi import Response
from patchright.async_api import Page, ProxySettings
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response, Security
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from starlette.concurrency import run_in_threadpool
from api import actions
from api.geoip import resolve_geo
from api.schemas import SolveRequest
from engine import AsyncStealthySession
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
from api.schemas import SolveRequest, SolveResponse, TaskCreated, TaskRequest, TaskResponse # noqa: E402
from db import clients # noqa: E402
from db.engine import connect as connect_db # noqa: E402
from db.models import Client # noqa: E402
from tasks import broker as queue # noqa: E402
from tasks.sessions import new_session_id, parse_session_id # noqa: E402
from tasks.store import Status, TaskStore, connect # noqa: E402
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
BROWSER_SLOTS = 10
RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5")
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503 no_slots
V1_QUEUE_WAIT = float(os.getenv("V1_QUEUE_WAIT", "30"))
POLL_INTERVAL = 0.2
store = TaskStore(connect())
database = connect_db()
def parse_proxy(proxy_url: str) -> ProxySettings:
parts = urlsplit(proxy_url)
server = f"{parts.scheme}://{parts.hostname}:{parts.port}"
settings: ProxySettings = {"server": server}
if parts.username:
settings["username"] = parts.username
if parts.password:
settings["password"] = parts.password
return settings
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.init()
yield
await database.close()
def make_page_action(payload: SolveRequest):
"""Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий
(включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже
навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что
вернёт fetch() (см. engine/stealthy.py)."""
async def page_action(page: Page) -> dict:
actions_result = await actions.execute(
page, payload.actions, payload.captcha,
antibot_name=payload.captcha_type,
success_locator=payload.captcha_success_locator,
detect_locator=payload.captcha_detect_locator,
)
return {
"actions": actions_result,
"cookies": await page.context.cookies(),
"html": await page.inner_html("html"),
"user_agent": await page.evaluate("() => navigator.userAgent"),
}
return page_action
app = FastAPI(lifespan=lifespan)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
app = FastAPI()
async def current_client(key: Annotated[str | None, Security(api_key_header)]) -> Client:
client = await clients.authenticate(database, key)
if client is None:
raise HTTPException(401, "invalid or missing X-API-Key")
return client
ClientDep = Annotated[Client, Depends(current_client)]
NO_SLOTS = {"status": "err", "code": "no_slots", "error": "No slots available"}
def no_slots_response() -> JSONResponse:
return JSONResponse(status_code=503, headers={"Retry-After": RETRY_AFTER}, content=NO_SLOTS)
def use_session(response: Response, cookie: str | None) -> str:
"""session_id из cookie или новый; cookie ставится в ответ."""
session_id = parse_session_id(cookie)
if cookie and not session_id:
logger.warning("invalid session_id cookie, issuing a new session")
session_id = session_id or new_session_id()
response.set_cookie("session_id", session_id)
return session_id
async def dispatch(message) -> None:
# pika блокирующий — отправка в пуле потоков, чтобы не останавливать event loop
await run_in_threadpool(queue.broker.enqueue, message)
async def submit(payload: TaskRequest, session_id: str, window: dict | None, client: Client, api: str) -> str:
"""Зарегистрировать задачу и поставить её в очередь: в персональную очередь воркера, где открыто
окно сессии (#83), иначе — в общую."""
task_id = str(uuid.uuid4())
await store.create(task_id, session_id, payload.url, client.id)
target = queue.personal_queue(window["worker_id"]) if window else queue.COMMON_QUEUE
await dispatch(queue.task_message(
task_id, session_id, payload.model_dump(mode="json"), payload.browser_idle_timeout, target,
meta={"client_id": client.id, "api": api},
))
logger.info("task %s [%s] -> %s: %s", task_id, session_id, target, payload.url or "<current page>")
return task_id
async def free_slots() -> int:
return sum(w["free_slots"] for w in await store.workers())
async def client_window(session_id: str, client: Client) -> dict | None:
"""Открытое окно сессии, если оно этого клиента. Чужое окно — как отсутствующее."""
window = await store.live_window(session_id)
return window if window and window.get("client_id") == str(client.id) else None
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/health")
async def health():
workers = await store.workers()
return {
"status": "ok",
"browser_slots": sum(w["browser_slots"] for w in workers),
"free_slots": sum(w["free_slots"] for w in workers),
"workers": workers,
}
@app.post("/v1/solve")
@app.post("/v2/tasks", status_code=202, response_model=TaskCreated)
async def create_task(
payload: TaskRequest,
response: Response,
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
session_id = use_session(response, session_id)
window = await client_window(session_id, client)
if payload.url is None and window is None:
raise HTTPException(422, "url is required: the session has no open browser window")
task_id = await submit(payload, session_id, window, client, "v2")
return {"task_id": task_id, "status": Status.QUEUED, "url": f"/v2/tasks/{task_id}"}
@app.get("/v2/tasks/{task_id}", response_model=TaskResponse, response_model_exclude_none=True)
async def get_task(task_id: str, client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
# задача видна только своему клиенту и своей сессии; чужая и несуществующая неразличимы
task = await store.get(task_id) if parse_session_id(task_id) else None
if not task or task["session_id"] != parse_session_id(session_id) or task.get("client_id") != str(client.id):
raise HTTPException(404, "task not found")
return {"task_id": task_id, "status": task["status"], "error": task.get("error"), "result": task.get("result")}
@app.delete("/v2/sessions/current")
async def close_session(client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
"""Закрыть окно сессии досрочно и освободить слот."""
session_id = parse_session_id(session_id)
window = await store.get_session(session_id) if session_id else None
if not window or window.get("client_id") != str(client.id):
return {"closed": False}
await store.delete_session(session_id)
if await store.worker_alive(window["worker_id"]):
await dispatch(queue.close_message(session_id, queue.personal_queue(window["worker_id"])))
return {"closed": True}
@app.post("/v1/solve", response_model=SolveResponse, response_model_exclude_none=True)
async def solve(
request: Request,
payload: SolveRequest,
response: Response,
session_id: Annotated[str | None, Cookie()] = None
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
# TODO: добавить поддержку api ключей
api_key = request.headers.get('X-API-Key')
"""Синхронный контракт v1: задача ставится в ту же очередь, ответ — когда воркер её выполнит.
Если свободных слотов нет или воркер не взял задачу за V1_QUEUE_WAIT — 503, как и раньше."""
started = time.monotonic()
session_id = use_session(response, session_id)
window = await client_window(session_id, client)
if window is None and await free_slots() <= 0:
logger.warning("no free browser slots, rejecting %s", payload.url)
return no_slots_response()
logger.info("Incoming payload: [%s]\n%s", session_id, payload.model_dump_json(indent=2, ensure_ascii=True))
global BROWSER_SLOTS
task_id = await submit(TaskRequest.model_validate(payload.model_dump()), session_id, window, client, "v1")
while True:
task = await store.get(task_id)
if task is None:
result = {"status": "err", "code": "request_failed", "error": "task expired"}
break
if task["status"] == Status.DONE:
result = task["result"]
break
if task["status"] in (Status.FAILED, Status.CANCELLED):
result = {"status": "err", "code": "request_failed", "error": task.get("error") or task["status"]}
break
if (
task["status"] == Status.QUEUED
and time.monotonic() - started > V1_QUEUE_WAIT
and await store.cancel(task_id)
):
logger.warning("task %s was not picked up in %ss, rejecting", task_id, V1_QUEUE_WAIT)
return no_slots_response()
await asyncio.sleep(POLL_INTERVAL)
if BROWSER_SLOTS <= 0:
return {
"ok": False,
"error": "No slots available",
}
BROWSER_SLOTS -= 1
try:
if not session_id:
session_id = str(uuid.uuid4())
response.set_cookie("session_id", session_id)
logger.info("Setup new session with session_id: %s", session_id)
proxy = parse_proxy(payload.proxy)
# профиль (cookies/local storage/fingerprint.json) хранится на диске по
# session_id — если он уже приходил в куках, ниже подгружаем существующий
# fingerprint из этой папки; если нет (новый session_id) — генерируем новый
# и сохраняем туда же (см. api/fingerprint.py:load_or_create_fingerprint)
user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}"
if not user_data_dir.exists():
user_data_dir.mkdir(parents=True)
# geo — до создания fingerprint'а, чтобы производная от прокси locale
# (см. api/geoip.py) попала в сам fingerprint, а не подменяла его
# задним числом
geo = await resolve_geo(proxy)
locale = geo.get("locale") if geo else None
fp_kwargs = {}
if payload.user_agent:
fp_kwargs["user_agent"] = payload.user_agent
# TODO: если есть session_id, то user_agent нужно взять из настроек пользователя
if locale:
fp_kwargs["locale"] = locale
nav_timeout_ms = payload.timeout * 1000 if payload.timeout else 60_000
blocked_domains = set(payload.blocked_domains) if payload.blocked_domains else None
kwargs = dict(
headless=False,
proxy=proxy,
user_data_dir=str(user_data_dir),
real_chrome=True,
locale=locale,
timezone_id=geo.get("timezone_id") if geo else None,
cookies=payload.cookies,
disable_resources=payload.disable_resources,
blocked_domains=blocked_domains,
timeout=nav_timeout_ms,
retries=max(1, payload.retries),
retry_delay=payload.retry_delay,
wait=payload.wait,
google_search=payload.google_search
)
async with AsyncStealthySession(**kwargs) as engine:
result = await engine.fetch(payload.url, page_action=make_page_action(payload))
except Exception as e:
traceback.print_exc()
result_payload = {
"error": str(e),
"status": False
}
else:
status = not "error" in result["actions"][-1]
error = ["Something error", None][status]
result_payload = {
"status": ["err", "ok"][status],
"user_agent": result["user_agent"],
"cookies": result["cookies"],
"actions": result["actions"],
}
if payload.html:
result_payload["html"] = result["html"]
if error:
result_payload["error"] = error
finally:
BROWSER_SLOTS += 1
logger.info("Outgoing payload:\n%s", json.dumps(result_payload, indent=2, sort_keys=True, ensure_ascii=True))
return result_payload
result.setdefault("actions", [])
result["duration_ms"] = int((time.monotonic() - started) * 1000)
return result
+5
View File
@@ -0,0 +1,5 @@
"""
Общее для сервисов api и worker (#78): хранилище задач в Redis, брокер dramatiq, сессии.
Пакет не зависит от браузера: образ api собирается без Chrome и Patchright.
"""
+53
View File
@@ -0,0 +1,53 @@
"""
Брокер dramatiq поверх RabbitMQ (#78) и сообщения, которыми api ставит работу воркерам.
Каждый воркер слушает общую очередь `robotex` и персональную `robotex.worker.<id>`. В персональную
api отправляет задачи сессии, чьё окно открыто на этом воркере (#83), иначе — в общую. Сообщение
адресуется actor'у по имени, поэтому api не импортирует код воркера (и Chrome ему не нужен).
`RABBITMQ_URL=stub://` — брокер в памяти для тестов.
"""
import os
import dramatiq
from dramatiq.brokers.stub import StubBroker
from dramatiq.middleware import AsyncIO
COMMON_QUEUE = "robotex"
RUN_TASK = "run_task"
CLOSE_WINDOW = "close_window"
def personal_queue(worker_id: str) -> str:
return f"{COMMON_QUEUE}.worker.{worker_id}"
def make_broker(url: str) -> dramatiq.Broker:
if url.startswith("stub://"):
broker = StubBroker()
else:
from dramatiq.brokers.rabbitmq import RabbitmqBroker
# confirm_delivery: enqueue возвращается, только когда RabbitMQ принял сообщение
broker = RabbitmqBroker(url=url, confirm_delivery=True)
broker.add_middleware(AsyncIO())
return broker
broker = make_broker(os.getenv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"))
dramatiq.set_broker(broker)
def task_message(
task_id: str, session_id: str, payload: dict, idle_timeout: int, queue: str = COMMON_QUEUE,
meta: dict | None = None,
) -> dramatiq.Message:
"""payload — SolveRequest/TaskRequest.model_dump(): в нём прокси и ключ 2captcha, поэтому
он живёт только в сообщении, а не в Redis. meta — для журнала: client_id, api (v1/v2)."""
return dramatiq.Message(
queue_name=queue, actor_name=RUN_TASK,
args=(task_id, session_id, payload, idle_timeout, meta or {}), kwargs={}, options={},
)
def close_message(session_id: str, queue: str) -> dramatiq.Message:
return dramatiq.Message(queue_name=queue, actor_name=CLOSE_WINDOW, args=(session_id,), kwargs={}, options={})
+16
View File
@@ -0,0 +1,16 @@
import uuid
def parse_session_id(value: str | None) -> str | None:
"""session_id из cookie. Принимается только UUID: id становится именем каталога профиля, и строка
вида ../.. не должна до него дойти. Невалидное значение — как отсутствие сессии."""
if not value:
return None
try:
return str(uuid.UUID(value))
except ValueError:
return None
def new_session_id() -> str:
return str(uuid.uuid4())
+204
View File
@@ -0,0 +1,204 @@
"""
Состояние задач, окон и воркеров в Redis (#78). Сроки жизни — TTL ключей Redis.
* `robotex:task:<id>` (hash) — статус задачи, сессия, клиент, время, результат. Пока задача не завершена,
у ключа страховочный TTL `PENDING_TTL`; при завершении — `RESULT_TTL` (`RESULT_TTL_HOURS`).
* `robotex:session:<id>` (hash) — где открыто окно сессии: `worker_id`, `idle_timeout`, `client_id`.
Пока задача выполняется, TTL — `RUNNING_TTL`; после неё — `browser_idle_timeout`. Ключ исчез —
окно закрывается (worker/windows.py).
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты и число окон.
Прокси и ключ 2captcha сюда не попадают: они передаются только в сообщении очереди.
"""
import json
import os
import time
from redis.asyncio import Redis
from redis.exceptions import WatchError
PREFIX = "robotex"
RESULT_TTL = int(float(os.getenv("RESULT_TTL_HOURS", "24")) * 3600)
# задача не может висеть в очереди или выполняться дольше: дальше её ключ просто исчезает
PENDING_TTL = 6 * 3600
# пока задача выполняется, ключ сессии держит маршрут к воркеру; дольше задача не живёт (TimeLimit)
RUNNING_TTL = 3 * 3600
HEARTBEAT_TTL = 30
# сколько раз задачу можно начать заново после падения воркера, прежде чем считать её проваленной
MAX_ATTEMPTS = 2
class Status:
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"
def connect(url: str | None = None) -> Redis:
return Redis.from_url(url or os.getenv("REDIS_URL", "redis://localhost:6379/0"), decode_responses=True)
def _task_key(task_id: str) -> str:
return f"{PREFIX}:task:{task_id}"
def _session_key(session_id: str) -> str:
return f"{PREFIX}:session:{session_id}"
def _worker_key(worker_id: str) -> str:
return f"{PREFIX}:worker:{worker_id}"
class TaskStore:
def __init__(self, redis: Redis):
self.redis = redis
async def ping(self) -> bool:
return await self.redis.ping()
# --- задачи ---
async def create(self, task_id: str, session_id: str, url: str | None, client_id: int | None = None) -> None:
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
pipe.hset(key, mapping={
"status": Status.QUEUED,
"session_id": session_id,
"client_id": "" if client_id is None else client_id,
"url": url or "",
"created_at": time.time(),
"attempts": 0,
})
pipe.expire(key, PENDING_TTL)
await pipe.execute()
async def get(self, task_id: str) -> dict | None:
data = await self.redis.hgetall(_task_key(task_id))
if not data:
return None
if "result" in data:
data["result"] = json.loads(data["result"])
return data
async def claim(self, task_id: str, worker_id: str, running_here: bool = False) -> bool:
"""Взять задачу в работу: queued → running. Атомарно (WATCH), чтобы задачу, отменённую
/v1/solve по таймауту очереди, никто не начал выполнять.
Задачу в статусе running можно взять заново, если её воркер умер (RabbitMQ вернул
неподтверждённое сообщение в очередь) — не больше MAX_ATTEMPTS раз. running_here — задача
уже выполняется в этом процессе: повторная доставка того же сообщения, второй раз не берём."""
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
data = await pipe.hgetall(key)
if not data:
return False
status = data["status"]
if status == Status.RUNNING:
owner = data.get("worker_id")
if running_here or (owner != worker_id and await self.worker_alive(owner)):
return False
if int(data.get("attempts", 0)) >= MAX_ATTEMPTS:
pipe.multi()
self._finish(pipe, key, Status.FAILED, error="worker died while running the task")
await pipe.execute()
return False
elif status != Status.QUEUED:
return False
pipe.multi()
pipe.hset(key, mapping={"status": Status.RUNNING, "worker_id": worker_id, "started_at": time.time()})
pipe.hincrby(key, "attempts", 1)
await pipe.execute()
return True
except WatchError:
continue
async def cancel(self, task_id: str) -> bool:
"""queued → cancelled; False, если воркер уже взял задачу."""
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
if await pipe.hget(key, "status") != Status.QUEUED:
return False
pipe.multi()
self._finish(pipe, key, Status.CANCELLED)
await pipe.execute()
return True
except WatchError:
continue
async def finish(self, task_id: str, result: dict) -> None:
async with self.redis.pipeline(transaction=True) as pipe:
self._finish(pipe, _task_key(task_id), Status.DONE, result=result)
await pipe.execute()
async def fail(self, task_id: str, error: str) -> None:
async with self.redis.pipeline(transaction=True) as pipe:
self._finish(pipe, _task_key(task_id), Status.FAILED, error=error)
await pipe.execute()
@staticmethod
def _finish(pipe, key: str, status: str, result: dict | None = None, error: str | None = None) -> None:
fields = {"status": status, "finished_at": time.time()}
if result is not None:
fields["result"] = json.dumps(result, ensure_ascii=False)
if error is not None:
fields["error"] = error
pipe.hset(key, mapping=fields)
pipe.expire(key, RESULT_TTL)
# --- окна сессий ---
async def set_session(
self, session_id: str, worker_id: str, idle_timeout: int, ttl: int, client_id: int | None = None,
) -> None:
key = _session_key(session_id)
async with self.redis.pipeline(transaction=True) as pipe:
pipe.hset(key, mapping={
"worker_id": worker_id, "idle_timeout": idle_timeout, "client_id": "" if client_id is None else client_id,
})
pipe.expire(key, ttl)
await pipe.execute()
async def get_session(self, session_id: str) -> dict | None:
return await self.redis.hgetall(_session_key(session_id)) or None
async def delete_session(self, session_id: str) -> None:
await self.redis.delete(_session_key(session_id))
async def session_ttl(self, session_id: str) -> int:
return await self.redis.ttl(_session_key(session_id))
async def live_window(self, session_id: str) -> dict | None:
"""Окно сессии, если оно открыто на живом воркере: {"worker_id", "idle_timeout"}."""
window = await self.get_session(session_id)
if window and await self.worker_alive(window["worker_id"]):
return window
return None
# --- воркеры ---
async def heartbeat(self, worker_id: str, info: dict) -> None:
await self.redis.set(_worker_key(worker_id), json.dumps(info), ex=HEARTBEAT_TTL)
async def remove_worker(self, worker_id: str) -> None:
await self.redis.delete(_worker_key(worker_id))
async def worker_alive(self, worker_id: str | None) -> bool:
return bool(worker_id) and bool(await self.redis.exists(_worker_key(worker_id)))
async def workers(self) -> list[dict]:
result = []
async for key in self.redis.scan_iter(match=_worker_key("*")):
info = await self.redis.get(key)
if info:
result.append({"worker_id": key.rsplit(":", 1)[-1], **json.loads(info)})
return sorted(result, key=lambda w: w["worker_id"])
+6
View File
@@ -0,0 +1,6 @@
"""
Сервис worker (#78): берёт задачи из очереди RabbitMQ, выполняет их в Chrome и кладёт результат в Redis.
Запуск: `dramatiq worker.actors --processes 1 --threads N` под Xvfb (см. Dockerfile). Процесс — строго
один: открытые окна браузера живут в его памяти.
"""
+112
View File
@@ -0,0 +1,112 @@
"""
Actor'ы dramatiq воркера. Точка входа: `dramatiq worker.actors --processes 1 --threads N`.
Actor'ы синхронные: каждый передаёт работу в общий event loop (поток AsyncIO-middleware), где живут
окна браузера, и ждёт её. Так возврат задачи в очередь (enqueue по pika, блокирующий) идёт из потока
dramatiq, а не из event loop.
"""
import asyncio
import logging
import os
import socket
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
import dramatiq # noqa: E402
from dramatiq.asyncio import get_event_loop_thread # noqa: E402
from dramatiq.middleware import Middleware # noqa: E402
from db.engine import connect as connect_db # noqa: E402
from db.journal import Journal # noqa: E402
from tasks import store as task_store # noqa: E402
from tasks.broker import CLOSE_WINDOW, COMMON_QUEUE, RUN_TASK, broker, personal_queue # noqa: E402
from worker.windows import Outcome, WindowPool # noqa: E402
log = logging.getLogger(__name__)
WORKER_ID = os.getenv("WORKER_ID") or socket.gethostname()
# сколько окон Chrome одновременно — и выполняющих задачу, и удерживаемых; ≈ числу ядер (docs/Deployment.md)
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4"))
# через сколько задача без свободного слота снова попадёт в очередь
NO_SLOT_DELAY_MS = 2000
HEARTBEAT_INTERVAL = 10
PROFILE_CLEANUP_INTERVAL = 3600
TASK_TIME_LIMIT_MS = task_store.RUNNING_TTL * 1000
_pool: WindowPool | None = None
_background: list[asyncio.Task] = []
def _run(coro):
return get_event_loop_thread().run_coroutine(coro)
async def _heartbeat_loop(pool: WindowPool) -> None:
"""heartbeat воркера (по нему api видит, жив ли воркер с окном) и закрытие простаивающих окон."""
while True:
try:
await pool.report()
await pool.expire_idle()
except Exception:
log.warning("idle check failed", exc_info=True)
await asyncio.sleep(HEARTBEAT_INTERVAL)
async def _cleanup_loop(pool: WindowPool) -> None:
"""Удаление старых профилей: сразу при старте и затем раз в час."""
while True:
try:
await pool.cleanup_profiles()
except Exception:
log.warning("profile cleanup failed", exc_info=True)
await asyncio.sleep(PROFILE_CLEANUP_INTERVAL)
async def _start() -> None:
global _pool
db = connect_db()
await db.init()
_pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS, Journal(db))
await _pool.report()
_background.extend([asyncio.create_task(_heartbeat_loop(_pool)), asyncio.create_task(_cleanup_loop(_pool))])
log.info("worker %s: %d browser slots, queues %s, %s", WORKER_ID, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(WORKER_ID))
async def _stop() -> None:
for task in _background:
task.cancel()
_background.clear()
await _pool.close_all()
await _pool.store.remove_worker(WORKER_ID)
if _pool.journal.db:
await _pool.journal.db.close()
class Lifecycle(Middleware):
"""Пул окон и heartbeat — в event loop AsyncIO-middleware: он поднимается раньше (middleware
добавлен в брокер раньше), а окна закрываются до его остановки."""
def before_worker_boot(self, broker, worker):
_run(_start())
def before_worker_shutdown(self, broker, worker):
_run(_stop())
broker.add_middleware(Lifecycle())
broker.declare_queue(personal_queue(WORKER_ID))
@dramatiq.actor(actor_name=RUN_TASK, queue_name=COMMON_QUEUE, max_retries=0, time_limit=TASK_TIME_LIMIT_MS)
def run_task(task_id: str, session_id: str, payload: dict, idle_timeout: int, meta: dict | None = None) -> None:
if _run(_pool.handle(task_id, session_id, payload, idle_timeout, meta)) == Outcome.NO_SLOT:
# в общую очередь: окно могло открыться на любом воркере, где есть слот
run_task.send_with_options(args=(task_id, session_id, payload, idle_timeout, meta), delay=NO_SLOT_DELAY_MS)
@dramatiq.actor(actor_name=CLOSE_WINDOW, queue_name=COMMON_QUEUE, max_retries=0)
def close_window(session_id: str) -> None:
_run(_pool.close(session_id))
+413
View File
@@ -0,0 +1,413 @@
"""
Слоты браузера и окна сессий воркера (#81, #82).
Слот — один открытый Chrome. `BROWSER_SLOTS` ограничивает число окон воркера: и тех, где идёт задача,
и удерживаемых между задачами. Окно сессии остаётся открытым после задачи, если в ней задан
`browser_idle_timeout > 0`: следующая задача с той же cookie `session_id` попадает в это окно
(маршрутизация — в api, #83) и продолжает работу без перезапуска Chrome. Время простоя считает
Redis: TTL ключа `session:<id>`, каждая задача в окно ставит его заново. Ключ исчез — окно
закрывается фоновой проверкой (`expire_idle`).
Задачи одной сессии выполняются строго по очереди: профиль Chrome нельзя открыть дважды.
Всё здесь работает в одном event loop (поток AsyncIO-middleware dramatiq), поэтому счётчик слотов
и словарь окон не требуют блокировок между потоками.
"""
import asyncio
import base64
import logging
import os
import shutil
import time
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from patchright.async_api import Page, ProxySettings
from api import actions, profile
from api.geoip import resolve_geo
from api.logs import safe_request_data, safe_request_dump, safe_response_dump
from api.schemas import TaskRequest
from db.journal import Journal
from engine import AsyncStealthySession
from tasks.sessions import parse_session_id
from tasks.store import RUNNING_TTL, Status, TaskStore
log = logging.getLogger(__name__)
USER_DATA_ROOT = (Path(__file__).parent / "../../extra/user_data_dir").resolve()
# профиль, которым не пользовались столько дней, удаляется (#20)
PROFILE_TTL_DAYS = float(os.getenv("PROFILE_TTL_DAYS", "7"))
# время последнего использования профиля — mtime этого файла
LAST_USED_FILE = ".robotex_last_used"
class Outcome:
DONE = "done"
# свободного слота нет — задачу нужно вернуть в очередь
NO_SLOT = "no_slot"
# задачу выполнять не нужно: отменена, уже выполнена или выполняется
SKIPPED = "skipped"
class NoWindow(Exception):
pass
@dataclass
class Window:
session_id: str
engine: Any = None
# прокси, с которым запущен Chrome: он задаётся при запуске, и задача с другим прокси перезапускает окно
proxy: str | None = None
# окно закрыть, как только закончится текущая задача (DELETE /v2/sessions/current)
closing: bool = False
client_id: int | None = None
# строка окна в журнале (#17): открыто — закрыто
journal_id: int | None = None
@dataclass
class _SessionLock:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
users: int = 0
def parse_proxy(proxy_url: str) -> ProxySettings:
parts = urlsplit(proxy_url)
settings: ProxySettings = {"server": f"{parts.scheme}://{parts.hostname}:{parts.port}"}
if parts.username:
settings["username"] = parts.username
if parts.password:
settings["password"] = parts.password
return settings
def session_dir(session_id: str) -> Path:
user_data_dir = (USER_DATA_ROOT / session_id).resolve()
if user_data_dir.parent != USER_DATA_ROOT:
raise ValueError("invalid session path")
return user_data_dir
def touch_profile(user_data_dir: Path) -> None:
(user_data_dir / LAST_USED_FILE).touch()
def profile_last_used(user_data_dir: Path) -> float:
try:
return (user_data_dir / LAST_USED_FILE).stat().st_mtime
except OSError:
# профиль из версии без отметки — по времени изменения каталога
return user_data_dir.stat().st_mtime
def make_page_action(payload: TaskRequest):
"""page_action для AsyncStealthySession.fetch()/run(): выполняет очередь действий (вместе с
прохождением антибота, api.actions.execute) и достаёт со страницы всё, что нужно для ответа.
Непройденный антибот не бросается наружу, а возвращается в challenge_error — чтобы в ответ
всё равно попали cookies, html и скриншот страницы-заглушки."""
async def page_action(page: Page) -> dict:
challenge_error = None
try:
actions_result = await actions.execute(
page, payload.actions, payload.captcha,
antibot_name=payload.captcha_type,
success_locator=payload.captcha_success_locator,
detect_locator=payload.captcha_detect_locator,
)
except actions.ChallengeFailed as e:
challenge_error = str(e)
actions_result = []
screenshot = None
if payload.screenshot:
screenshot = base64.b64encode(await page.screenshot(type="png")).decode()
return {
"challenge_error": challenge_error,
"actions": actions_result,
"cookies": await page.context.cookies(),
"html": await page.inner_html("html"),
"screenshot": screenshot,
"user_agent": await page.evaluate("() => navigator.userAgent"),
}
return page_action
def build_result(payload: TaskRequest, result: dict) -> dict:
"""Ответ в формате SolveResponse из результата page_action."""
failed = [a for a in result["actions"] if a.get("ok") is False]
response = {
"status": "ok",
"user_agent": result["user_agent"],
"cookies": result["cookies"],
"actions": result["actions"],
}
if result["challenge_error"]:
response.update(status="err", code="challenge_failed", error=result["challenge_error"])
elif failed:
response.update(status="err", code="action_failed", error=f"{failed[0]['type']}: {failed[0].get('result')}")
if payload.html:
response["html"] = result["html"]
if payload.screenshot:
response["screenshot"] = result["screenshot"]
return response
class WindowPool:
def __init__(self, store: TaskStore, worker_id: str, slots: int, journal: Journal | None = None):
self.store = store
self.journal = journal or Journal(None)
self.worker_id = worker_id
self.slots = slots
self.used = 0
self.windows: dict[str, Window] = {}
self.running: set[str] = set()
self._locks: dict[str, _SessionLock] = {}
def info(self) -> dict:
return {
"browser_slots": self.slots,
"free_slots": self.slots - self.used,
"windows": len(self.windows),
"running": len(self.running),
}
@asynccontextmanager
async def _session_lock(self, session_id: str):
entry = self._locks.setdefault(session_id, _SessionLock())
entry.users += 1
try:
async with entry.lock:
yield
finally:
entry.users -= 1
if not entry.users:
del self._locks[session_id]
async def report(self) -> None:
"""heartbeat со слотами сразу после их изменения: по нему api решает, отвечать ли 503 на /v1/solve."""
try:
await self.store.heartbeat(self.worker_id, self.info())
except Exception:
log.warning("heartbeat failed", exc_info=True)
def _busy(self, session_id: str) -> bool:
entry = self._locks.get(session_id)
return bool(entry and entry.lock.locked())
async def handle(
self, task_id: str, session_id: str, payload: dict, idle_timeout: int, meta: dict | None = None,
) -> str:
"""Выполнить задачу. Результат — в Redis; возврат — что делать с сообщением очереди.
meta: client_id и api (v1/v2) — для журнала."""
meta = meta or {}
client_id = meta.get("client_id")
async with self._session_lock(session_id):
window = self.windows.get(session_id)
new_window = window is None
if new_window:
if self.used >= self.slots:
task = await self.store.get(task_id)
# отменённую или истёкшую задачу незачем возвращать в очередь
return Outcome.NO_SLOT if task and task["status"] == Status.QUEUED else Outcome.SKIPPED
# слот занимается до первого await, иначе его может перехватить другая задача
self.used += 1
window = self.windows[session_id] = Window(session_id, client_id=client_id)
await self.report()
if not await self.store.claim(task_id, self.worker_id, running_here=task_id in self.running):
if new_window:
await self._close(window)
return Outcome.SKIPPED
self.running.add(task_id)
task = TaskRequest.model_validate(payload)
result = None
queued = await self.store.get(task_id) or {}
await self.journal.request_started(
task_id, client_id, session_id, meta.get("api", "v2"),
float(queued["created_at"]) if queued.get("created_at") else None, safe_request_data(task),
)
try:
await self.store.set_session(session_id, self.worker_id, idle_timeout, RUNNING_TTL, client_id)
result = await self._execute(window, task, task_id)
await self.store.finish(task_id, result)
except Exception as e:
log.exception("task %s failed", task_id)
with suppress(Exception):
await self.store.fail(task_id, str(e) or type(e).__name__)
except asyncio.CancelledError:
# dramatiq прервал задачу по time limit или при остановке воркера
with suppress(Exception):
await self.store.fail(task_id, "task interrupted")
raise
finally:
self.running.discard(task_id)
await self.journal.request_finished(task_id, window.journal_id, result)
if idle_timeout and window.engine and not window.closing:
await self.store.set_session(session_id, self.worker_id, idle_timeout, idle_timeout * 60, client_id)
else:
await self._close(window)
with suppress(Exception):
await self.store.delete_session(session_id)
return Outcome.DONE
async def _execute(self, window: Window, payload: TaskRequest, task_id: str) -> dict:
started = time.monotonic()
session_id = window.session_id
log.info("Incoming request [%s]: %s", session_id, payload.url or "<current page>")
log.debug("Incoming payload [%s]:\n%s", session_id, safe_request_dump(payload))
page_action = make_page_action(payload)
nav_kwargs = dict(
timeout=payload.timeout * 1000 if payload.timeout else 60_000,
disable_resources=payload.disable_resources,
blocked_domains=set(payload.blocked_domains) if payload.blocked_domains else None,
)
try:
if window.engine and window.proxy != payload.proxy:
log.info("proxy changed, restarting browser [%s]", session_id)
await self._close_engine(window)
if window.engine is None:
if not payload.url:
raise NoWindow("url is required: the session has no open browser window")
window.engine = await self._open(session_id, payload, nav_kwargs)
window.proxy = payload.proxy
window.journal_id = await self.journal.window_opened(window.client_id, session_id, task_id, self.worker_id)
else:
await self.journal.window_used(window.journal_id)
if payload.cookies is not None:
# переданные cookies заменяют cookies профиля, как и при запуске окна
await window.engine.context.clear_cookies()
if payload.cookies:
await window.engine.context.add_cookies(payload.cookies)
if payload.url:
raw = await window.engine.fetch(
payload.url, page_action=page_action,
wait=payload.wait, google_search=payload.google_search, **nav_kwargs,
)
else:
raw = await window.engine.run(page_action, **nav_kwargs)
except Exception as e:
if not isinstance(e, NoWindow):
log.exception("request failed [%s]", session_id)
# окно могло остаться в непонятном состоянии: следующая задача откроет его заново из профиля
await self._close_engine(window)
result = {"status": "err", "code": "request_failed", "error": str(e) or type(e).__name__}
else:
result = build_result(payload, raw)
result["duration_ms"] = int((time.monotonic() - started) * 1000)
log.info("Outgoing [%s]: %s %s %dms", session_id, result["status"], result.get("error", ""), result["duration_ms"])
log.debug("Outgoing payload:\n%s", safe_response_dump(result))
return result
async def _open(self, session_id: str, payload: TaskRequest, nav_kwargs: dict):
user_data_dir = session_dir(session_id)
user_data_dir.mkdir(parents=True, exist_ok=True)
touch_profile(user_data_dir)
# отпечаток задаётся профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
proxy = parse_proxy(payload.proxy) if payload.proxy else None
# geo — по exit-IP прокси (см. api/geoip.py); без прокси оставляем настройки браузера
geo = await resolve_geo(proxy) if proxy else None
engine = AsyncStealthySession(
headless=False,
proxy=proxy,
user_data_dir=str(user_data_dir),
real_chrome=True,
locale=geo.get("locale") if geo else None,
timezone_id=geo.get("timezone_id") if geo else None,
cookies=payload.cookies,
# переданные cookies заменяют cookies профиля, а не дополняют их
clear_cookies=payload.cookies is not None,
retries=max(1, payload.retries),
retry_delay=payload.retry_delay,
wait=payload.wait,
google_search=payload.google_search,
**nav_kwargs,
**profile_kwargs,
)
await engine.start()
return engine
async def _close_engine(self, window: Window) -> None:
engine, window.engine = window.engine, None
journal_id, window.journal_id = window.journal_id, None
await self.journal.window_closed(journal_id)
if engine is not None:
try:
await engine.close()
except Exception:
log.warning("failed to close browser [%s]", window.session_id, exc_info=True)
async def _close(self, window: Window) -> None:
"""Закрыть окно и освободить его слот."""
if window.engine is not None:
with suppress(OSError):
touch_profile(session_dir(window.session_id))
await self._close_engine(window)
if self.windows.get(window.session_id) is window:
del self.windows[window.session_id]
self.used -= 1
log.info("window closed [%s]", window.session_id)
await self.report()
async def close(self, session_id: str) -> None:
"""Закрыть окно сессии досрочно. Если в нём идёт задача — сразу после неё."""
window = self.windows.get(session_id)
if window is None:
return
window.closing = True
if self._busy(session_id):
return
async with self._session_lock(session_id):
if self.windows.get(session_id) is window:
await self._close(window)
async def expire_idle(self) -> None:
"""Закрыть окна, чей ключ сессии в Redis истёк (простой дольше browser_idle_timeout)."""
for session_id, window in list(self.windows.items()):
if self._busy(session_id) or await self.store.get_session(session_id):
continue
async with self._session_lock(session_id):
if self.windows.get(session_id) is window and not await self.store.get_session(session_id):
log.info("browser idle timeout [%s]", session_id)
await self._close(window)
async def close_all(self) -> None:
for session_id, window in list(self.windows.items()):
await self._close(window)
with suppress(Exception):
await self.store.delete_session(session_id)
async def cleanup_profiles(self, ttl_days: float = PROFILE_TTL_DAYS) -> list[str]:
"""Удалить профили, которыми не пользовались ttl_days дней (#20). Профиль с открытым окном —
на этом воркере или на другом (ключ session:<id> в Redis) — не трогается."""
if not USER_DATA_ROOT.is_dir():
return []
deadline = time.time() - ttl_days * 86400
removed = []
for user_data_dir in USER_DATA_ROOT.iterdir():
session_id = parse_session_id(user_data_dir.name)
if not session_id or not user_data_dir.is_dir() or profile_last_used(user_data_dir) > deadline:
continue
if session_id in self.windows or self._busy(session_id) or await self.store.get_session(session_id):
continue
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
removed.append(session_id)
if removed:
log.info("removed %d browser profiles unused for %s days", len(removed), ttl_days)
return removed
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+223
View File
@@ -0,0 +1,223 @@
"""Сквозные тесты с настоящим Chrome и сетью: api и worker в одном процессе (tests/harness.py), без
RabbitMQ и Redis. Запускаются в Docker (нужны Chrome и Xvfb):
make test-browser
Весь стенд с очередью проверяет make smoke.
"""
import json
import re
import uuid
import pytest
from api import profile
from tests.harness import run_env
from worker import windows
pytestmark = pytest.mark.browser
CF_URL = "https://freelancehunt.com/projects"
CF_SELECTOR = 'a[href*="/project/"]'
# то, что обычно входит в хеш fingerprint-библиотек и видно странице
FP_JS = """async () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(10, 10, 100, 30); x.fillStyle = '#069'; x.fillText('Robotex fp', 4, 20);
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(c.toDataURL()));
const uad = await navigator.userAgentData.getHighEntropyValues(['fullVersionList']);
document.body.innerHTML = '<pre id="fp">' + JSON.stringify({
ua: navigator.userAgent,
ua_ch: uad.fullVersionList.map(b => b.brand + '/' + b.version.split('.')[0]).join(','),
screen: [screen.width, screen.height, devicePixelRatio],
canvas: Array.from(new Uint8Array(buf)).slice(0, 6).map(b => b.toString(16).padStart(2, '0')).join(''),
}) + '</pre>';
}"""
# пустой canvas не зашумлён, подменённые функции выглядят нативными
NOISE_JS = """() => {
const blank = document.createElement('canvas');
blank.width = 50; blank.height = 50;
const empty = document.createElement('canvas');
empty.width = 50; empty.height = 50;
const data = blank.getContext('2d').getImageData(0, 0, 50, 50).data;
return {
blank_same: blank.toDataURL() === empty.toDataURL(),
blank_zero: data.every(v => v === 0),
native: [HTMLCanvasElement.prototype.toDataURL, HTMLCanvasElement.prototype.toBlob,
CanvasRenderingContext2D.prototype.getImageData, Function.prototype.toString]
.every(f => /\\{ \\[native code\\] \\}$/.test(Function.prototype.toString.call(f)) && /native code/.test(String(f))),
lengths: [HTMLCanvasElement.prototype.toDataURL.length, CanvasRenderingContext2D.prototype.getImageData.length],
};
}"""
@pytest.fixture
def env(monkeypatch, tmp_path):
with run_env(monkeypatch, tmp_path) as harness:
yield harness
@pytest.fixture
def client(env):
return env.client
def solve(client, session_id=None, **body):
client.cookies.clear()
if session_id:
client.cookies.set("session_id", session_id)
resp = client.post("/v1/solve", json={"actions": [], **body})
assert resp.status_code == 200, resp.text
return resp.json() | {"session_id": resp.cookies["session_id"]}
def test_basic_page(client):
data = solve(client, url="https://example.com", screenshot=True, actions=[
{"type": "wait_for", "selector": "p", "timeout": 10},
{"type": "inner_html", "selector": "title"},
])
assert data["status"] == "ok", data
assert data["actions"][1]["result"] == "Example Domain"
assert len(data["screenshot"]) > 1000
def test_action_failed_on_real_page(client):
data = solve(client, url="https://example.com", actions=[{"type": "inner_html", "selector": "#missing"}])
assert data["code"] == "action_failed"
assert data["error"] == "inner_html: selector not found: #missing"
def test_cookies_replace_profile_cookies(client):
url = "https://httpbin.org/cookies"
read = [{"type": "inner_html", "selector": "pre"}]
cookie = lambda name: {"name": name, "value": "1", "url": "https://httpbin.org", "expires": 4102444800}
first = solve(client, url=url, cookies=[cookie("a")], actions=read)
sid = first["session_id"]
assert json.loads(first["actions"][0]["result"])["cookies"] == {"a": "1"}
# без cookies в запросе профильные сохраняются
second = solve(client, sid, url=url, actions=read)
assert json.loads(second["actions"][0]["result"])["cookies"] == {"a": "1"}
# переданные cookies заменяют профильные
third = solve(client, sid, url=url, cookies=[cookie("b")], actions=read)
assert json.loads(third["actions"][0]["result"])["cookies"] == {"b": "1"}
def test_fingerprint_applied_and_stable(client, monkeypatch):
async def page_fp(page):
# в основном мире страницы, как скрипты сайта (по умолчанию Patchright выполняет в изолированном)
await page.evaluate(FP_JS, isolated_context=False)
return json.loads(await page.inner_html("#fp"))
results = {}
original = windows.make_page_action
def capture(payload):
action = original(payload)
async def wrapped(page):
out = await action(page)
out["fp"] = await page_fp(page)
return out
return wrapped
monkeypatch.setattr(windows, "make_page_action", capture)
class Spy(windows.AsyncStealthySession):
async def fetch(self, url, page_action, **kwargs):
out = await super().fetch(url, page_action=page_action, **kwargs)
results.setdefault(self._user_data_dir, []).append(out["fp"])
return out
monkeypatch.setattr(windows, "AsyncStealthySession", Spy)
sids = [solve(client, url="https://example.com")["session_id"] for _ in range(4)]
solve(client, sids[0], url="https://example.com")
fps = {}
for d, measured in results.items():
saved = profile.load(windows.USER_DATA_ROOT / d.rsplit("/", 1)[-1])
fp = saved["fingerprint"]
for m in measured:
assert m["screen"] == [fp["screen"]["width"], fp["screen"]["height"], fp["device_scale_factor"]]
# UA родной: версия в строке UA совпадает с userAgentData
major = re.search(r"Google Chrome/(\d+)", m["ua_ch"]).group(1)
assert f"Chrome/{major}." in m["ua"]
assert all(m == measured[0] for m in measured), "fingerprint changed within a profile"
fps[d] = measured[0]
assert len(fps) == 4
# шум canvas (#89): у каждого профиля свой хеш canvas, значит и отпечаток целиком
assert len({f["canvas"] for f in fps.values()}) == 4
assert len({json.dumps(f, sort_keys=True) for f in fps.values()}) == 4
def test_canvas_noise_is_invisible(client, monkeypatch):
"""Шум не трогает пустой canvas, а подменённые функции неотличимы от нативных по toString и length."""
seen = {}
original = windows.make_page_action
def capture(payload):
action = original(payload)
async def wrapped(page):
seen.update(await page.evaluate(NOISE_JS, isolated_context=False))
return await action(page)
return wrapped
monkeypatch.setattr(windows, "make_page_action", capture)
assert solve(client, url="https://example.com")["status"] == "ok"
assert seen == {"blank_same": True, "blank_zero": True, "native": True, "lengths": [0, 4]}
def test_ua_in_response_is_native(client):
"""user_agent в ответе — родной UA браузера, тот, с которым получен cf_clearance."""
data = solve(client, url="https://example.com")
assert re.fullmatch(r"Mozilla/5\.0 \(X11; Linux x86_64\) .* Chrome/\d+\.0\.0\.0 Safari/537\.36", data["user_agent"])
assert "Headless" not in data["user_agent"]
def test_cloudflare(client):
data = solve(client, url=CF_URL, captcha_type="cloudflare", timeout=120, actions=[
{"type": "wait_for", "selector": CF_SELECTOR, "timeout": 60},
])
assert data["status"] == "ok", data.get("error")
assert "cf_clearance" in {c["name"] for c in data["cookies"]}
assert uuid.UUID(data["session_id"])
def test_window_held_between_tasks(env):
"""browser_idle_timeout: вторая задача без url выполняется в том же окне, на уже открытой странице."""
read = [{"type": "inner_html", "selector": "title"}]
client = env.client
first = client.post("/v2/tasks", json={"url": "https://example.com", "actions": read, "browser_idle_timeout": 1})
task = client.get(first.json()["url"]).json()
assert task["result"]["status"] == "ok", task
assert env.pool.used == 1
second = client.post("/v2/tasks", json={"actions": read, "browser_idle_timeout": 1}).json()
result = client.get(second["url"]).json()["result"]
assert result["status"] == "ok", result
assert result["actions"][0]["result"] == "Example Domain"
# окно не перезапускалось: страница не открывалась заново
assert result["duration_ms"] < task["result"]["duration_ms"]
assert client.delete("/v2/sessions/current").json() == {"closed": True}
assert env.pool.used == 0
def test_typing_like_a_human(client):
"""typing: клик по полю и посимвольный ввод, включая кириллицу; поле сначала очищается."""
data = solve(client, url="https://httpbin.org/forms/post", actions=[
{"type": "fill", "selector": "input[name=custname]", "value": "old value"},
{"type": "typing", "selector": "input[name=custname]", "value": "Иван Smith-2"},
{"type": "submit", "selector": "form button"},
{"type": "inner_text", "selector": "body"},
])
assert data["status"] == "ok", data
assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2"
assert data["actions"][1]["duration_ms"] > 12 * 50 # посимвольно, с паузами
+11
View File
@@ -0,0 +1,11 @@
import os
import pytest
# брокер dramatiq в памяти: tasks.broker создаётся при импорте
os.environ.setdefault("RABBITMQ_URL", "stub://")
@pytest.fixture
def anyio_backend():
return "asyncio"
+86
View File
@@ -0,0 +1,86 @@
"""api и worker в одном процессе без RabbitMQ и Redis: Redis — fakeredis, отправка в очередь подменена
доставкой сообщения прямо в WindowPool воркера (tests/unit, tests/browser)."""
import asyncio
import json
from contextlib import contextmanager
import fakeredis
from fastapi.testclient import TestClient
import main
from db import clients
from db.engine import connect as connect_db
from db.journal import Journal
from tasks import store as task_store
from tasks.broker import RUN_TASK
from worker import windows
WORKER_ID = "w1"
class Harness:
def __init__(self, store, pool, redis_sync):
self.store = store
self.pool = pool
self.redis = redis_sync
self.messages = []
self.outcomes = []
# True — сообщение выполняется сразу при отправке; False — копится в messages до deliver()
self.auto = True
self.client: TestClient | None = None
async def dispatch(self, message):
self.messages.append(message)
if self.auto:
await self.deliver(message)
async def deliver(self, message):
if message.actor_name == RUN_TASK:
self.outcomes.append(await self.pool.handle(*message.args))
else:
await self.pool.close(*message.args)
await self.store.heartbeat(WORKER_ID, self.pool.info())
def deliver_pending(self):
pending, self.messages = self.messages, []
for message in pending:
self.client.portal.call(self.deliver, message)
def heartbeat(self, **info):
self.redis.set(f"{task_store.PREFIX}:worker:{WORKER_ID}", json.dumps(self.pool.info() | info))
def call(self, fn, *args):
return self.client.portal.call(fn, *args)
def make_client_key(db, name="test") -> str:
async def create():
await db.init()
return (await clients.create_client(db, name))[1]
key = asyncio.run(create())
asyncio.run(db.close()) # соединения sqlite привязаны к циклу asyncio.run — закрываем, приложение откроет свои
return key
@contextmanager
def run_env(monkeypatch, tmp_path, slots=2):
server = fakeredis.FakeServer()
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
db = connect_db(f"sqlite+aiosqlite:///{tmp_path / 'robotex.db'}")
key = make_client_key(db)
pool = windows.WindowPool(store, WORKER_ID, slots=slots, journal=Journal(db))
harness = Harness(store, pool, fakeredis.FakeRedis(server=server, decode_responses=True))
harness.db, harness.api_key = db, key
# профили — в отдельном каталоге: в tmp_path ещё и файл базы
profiles = tmp_path / "profiles"
profiles.mkdir()
harness.profiles = profiles
monkeypatch.setattr(windows, "USER_DATA_ROOT", profiles)
monkeypatch.setattr(main, "store", store)
monkeypatch.setattr(main, "database", db)
monkeypatch.setattr(main, "dispatch", harness.dispatch)
harness.heartbeat()
with TestClient(main.app, headers={"X-API-Key": key}) as client:
harness.client = client
yield harness
client.portal.call(pool.close_all)
+173
View File
@@ -0,0 +1,173 @@
"""Нагрузочный тест: N параллельных запросов на каждом уровне (каждый — в своей сессии) + пиковое
потребление контейнера воркера по `docker stats`.
docker compose --profile loadtest up -d
python tests/loadtest.py --api-key rbx_... --levels 1 5 10 15 # /v1/solve
python tests/loadtest.py --api-key rbx_... --mode v2 --levels 4 8 # /v2/tasks + опрос
Помимо status ответа, неуспех раскладывается по причинам: нет слотов, застряли на Cloudflare,
упало действие, исключение до действий.
"""
import argparse
import asyncio
import json
import os
import statistics
import subprocess
import threading
import time
import httpx
CF_MARKERS = ("challenges.cloudflare.com", "Just a moment", "cf-turnstile")
def build_payload(args) -> dict:
return {
"url": args.url,
"proxy": args.proxy,
"timeout": 120,
"html": True,
"captcha_type": args.captcha_type,
"actions": [{"type": "wait_for", "selector": args.selector, "timeout": 60}],
}
def classify(resp: httpx.Response | Exception) -> str:
if isinstance(resp, Exception):
return f"exc:{type(resp).__name__}"
data = resp.json()
code = data.get("code")
if code == "no_slots":
return "no_slots"
if code == "challenge_failed":
return "cf_blocked"
if code == "action_failed":
html = data.get("html") or ""
return "cf_blocked" if any(m in html for m in CF_MARKERS) else "action_failed"
if data.get("status") != "ok":
return f"error:{str(data.get('error'))[:60]}"
return "ok"
class StatsSampler(threading.Thread):
"""Раз в ~2с снимает docker stats контейнера, копит пики CPU/памяти и число процессов."""
def __init__(self, container: str):
super().__init__(daemon=True)
self.container = container
self.stop_event = threading.Event()
self.cpu = 0.0
self.mem_mb = 0.0
self.pids = 0
@staticmethod
def _to_mb(value: str) -> float:
units = {"KiB": 1 / 1024, "MiB": 1, "GiB": 1024, "kB": 1 / 1000, "MB": 1, "GB": 1000, "B": 1 / 2**20}
for unit, k in units.items():
if value.endswith(unit):
return float(value[: -len(unit)]) * k
return 0.0
def run(self):
while not self.stop_event.is_set():
out = subprocess.run(
["docker", "stats", "--no-stream", "--format", "{{json .}}", self.container],
capture_output=True, text=True,
).stdout.strip()
if out:
s = json.loads(out)
self.cpu = max(self.cpu, float(s["CPUPerc"].rstrip("%")))
self.mem_mb = max(self.mem_mb, self._to_mb(s["MemUsage"].split("/")[0].strip()))
self.pids = max(self.pids, int(s["PIDs"]))
self.stop_event.wait(1)
class TaskResult:
"""Ответ /v2 в виде, понятном classify(): status_code и json()."""
def __init__(self, status_code: int, data: dict):
self.status_code, self._data = status_code, data
def json(self) -> dict:
return self._data
async def solve_v2(client: httpx.AsyncClient, api: str, payload: dict):
created = await client.post(f"{api}/v2/tasks", json=payload)
if created.status_code != 202:
return created
url = f"{api}{created.json()['url']}"
while True:
await asyncio.sleep(0.5)
task = (await client.get(url)).json()
if task["status"] == "done":
return TaskResult(200, task["result"])
if task["status"] not in ("queued", "running"):
return TaskResult(200, {"status": "err", "code": task["status"], "error": task.get("error")})
async def one(args, payload: dict) -> tuple[str, float]:
# у каждого запроса своя сессия: общий клиент запомнил бы cookie session_id, и задачи одной
# сессии выполнялись бы по очереди
headers = {"X-API-Key": args.api_key} if args.api_key else {}
t0 = time.monotonic()
async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client:
try:
if args.mode == "v2":
resp = await solve_v2(client, args.api, payload)
else:
resp = await client.post(f"{args.api}/v1/solve", json=payload)
except Exception as e:
resp = e
return classify(resp), time.monotonic() - t0
async def run_level(n: int, args) -> None:
sampler = StatsSampler(args.container)
sampler.start()
t0 = time.monotonic()
results = await asyncio.gather(*(one(args, build_payload(args)) for _ in range(n)))
wall = time.monotonic() - t0
sampler.stop_event.set()
sampler.join()
outcomes: dict[str, int] = {}
for outcome, _ in results:
outcomes[outcome] = outcomes.get(outcome, 0) + 1
ok_times = [d for o, d in results if o == "ok"]
median = f"{statistics.median(ok_times):.1f}" if ok_times else "-"
print(
f"| {n:>3} | {outcomes.get('ok', 0):>3}/{n:<3} | {median:>8} | {wall:>7.1f} "
f"| {sampler.cpu:>7.0f} | {sampler.mem_mb / 1024:>7.2f} | {sampler.pids:>5} | "
+ ", ".join(f"{k}={v}" for k, v in sorted(outcomes.items()) if k != "ok"),
flush=True,
)
async def main():
p = argparse.ArgumentParser()
p.add_argument("--api", default="http://127.0.0.1:8000")
p.add_argument("--url", default="https://freelancehunt.com/projects")
p.add_argument("--selector", default='a[href*="/project/"]')
p.add_argument("--proxy", default="http://proxy:8080")
p.add_argument("--captcha-type", default="cloudflare")
p.add_argument("--mode", choices=["v1", "v2"], default="v1", help="v1 — /v1/solve, v2 — /v2/tasks с опросом")
p.add_argument("--api-key", default=os.getenv("ROBOTEX_API_KEY"), help="X-API-Key (или ROBOTEX_API_KEY)")
# Chrome работает в воркере — его docker stats и снимаем
p.add_argument("--container", default="robotex-worker-1")
p.add_argument("--levels", type=int, nargs="+", default=[1, 5, 10])
p.add_argument("--pause", type=float, default=10, help="пауза между уровнями, с")
p.add_argument("--http-timeout", type=float, default=300)
args = p.parse_args()
print("| par | ok | med ok,s | wall,s | peak CPU% | peak GiB | PIDs | прочее")
print("|-----|---------|----------|---------|-----------|----------|------|-------")
for i, n in enumerate(args.levels):
if i:
await asyncio.sleep(args.pause)
await run_level(n, args)
if __name__ == "__main__":
asyncio.run(main())
+85
View File
@@ -0,0 +1,85 @@
"""Проверка запущенного стенда (make up): api, очередь, воркер с Chrome, удержание окна.
make smoke API_KEY=rbx_... # http://localhost:8000
make smoke API_KEY=rbx_... API_URL=https://app.robotex.sokolab.xyz
Ключ: make key-create name=smoke.
"""
import sys
import time
import httpx
URL = "https://example.com"
READ_TITLE = [{"type": "inner_html", "selector": "title"}]
def check(cond: bool, what: str) -> None:
print((" ok " if cond else " FAIL ") + what)
if not cond:
sys.exit(1)
def wait_task(client: httpx.Client, task_id: str, timeout: float = 180) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
task = client.get(f"/v2/tasks/{task_id}").json()
if task["status"] not in ("queued", "running"):
return task
time.sleep(0.5)
raise TimeoutError(f"task {task_id} not finished in {timeout}s")
def windows(client: httpx.Client) -> int:
return sum(w["windows"] for w in client.get("/health").json()["workers"])
def main(base_url: str, api_key: str) -> None:
headers = {"X-API-Key": api_key}
client = httpx.Client(base_url=base_url, timeout=300, headers=headers)
print("auth")
resp = httpx.post(f"{base_url}/v1/solve", json={"url": URL, "actions": []}, timeout=30)
check(resp.status_code == 401, f"no key -> {resp.status_code}")
print("health")
health = client.get("/health").json()
check(len(health["workers"]) > 0, f"workers: {[w['worker_id'] for w in health['workers']]}, free slots {health['free_slots']}")
print("v1 /v1/solve")
started = time.monotonic()
data = client.post("/v1/solve", json={"url": URL, "actions": READ_TITLE}).json()
check(data["status"] == "ok", f"status {data['status']} {data.get('error', '')} ({time.monotonic() - started:.1f}s)")
check(data["actions"][0]["result"] == "Example Domain", "title = Example Domain")
print("v2 window held between tasks")
v2 = httpx.Client(base_url=base_url, timeout=60, headers=headers) # своя сессия
started = time.monotonic()
created = v2.post("/v2/tasks", json={"url": URL, "actions": READ_TITLE, "browser_idle_timeout": 2})
check(created.status_code == 202, f"POST /v2/tasks -> {created.status_code}")
task = wait_task(v2, created.json()["task_id"])
check(task["status"] == "done" and task["result"]["status"] == "ok", f"first task done ({time.monotonic() - started:.1f}s)")
check(windows(client) >= 1, "window stays open")
started = time.monotonic()
created = v2.post("/v2/tasks", json={"actions": READ_TITLE, "browser_idle_timeout": 2})
task = wait_task(v2, created.json()["task_id"])
check(
task["status"] == "done" and task["result"]["actions"][0]["result"] == "Example Domain",
f"second task without url ran in the open window ({time.monotonic() - started:.1f}s)",
)
check(client.get(f"/v2/tasks/{created.json()['task_id']}").status_code == 404, "task hidden from other sessions")
check(v2.delete("/v2/sessions/current").json() == {"closed": True}, "DELETE /v2/sessions/current")
deadline = time.monotonic() + 15
while windows(client) and time.monotonic() < deadline:
time.sleep(0.5)
check(windows(client) == 0, "window closed, slot released")
print("all good")
if __name__ == "__main__":
if len(sys.argv) < 3 or not sys.argv[2]:
sys.exit("usage: smoke.py <api url> <api key> (make smoke API_KEY=...)")
main(sys.argv[1], sys.argv[2])
+18 -8
View File
@@ -109,19 +109,29 @@ X-API-Key: <key>
### 2f2100be-6ce7-40d5-bae5-61424b2504c8
POST http://127.0.0.1:8000/v1/solve
POST http://localhost:8002/v2/tasks
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
{
"url": "https://ifconfig.me/",
"proxy": "http://7LLkWF:yecUzT@200.10.32.226:8000",
"actions": [
{"type": "wait_for", "selector": "#ip_address", "timeout": 10},
{"type": "inner_html", "selector": "#ip_address"}
]
"url": "https://freelancehunt.com/",
"proxy": "http://BQLo3X:poqAWX@168.81.43.154:8000",
"actions": [
{
"type": "inner_text",
"selector": "//section[@class='hero']//div[@class='hero__content']/h2/span[2]"
}
],
"captcha_type": "cloudflare",
"browser_idle_timeout": 0
}
###
### b8f770a3-38c2-45f2-944b-2703798e3503
GET http://localhost:8002/v2/tasks/4d8105f8-93a8-43ee-aa6e-db917451363b
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
###
View File
Whitespace-only changes.
+80
View File
@@ -0,0 +1,80 @@
"""Сервисы api и worker без браузера: Chrome — FakeSession (окружение — tests/harness.py)."""
import pytest
from api import actions
from tests.harness import WORKER_ID, run_env # noqa: F401
from worker import windows
class FakeContext:
def __init__(self):
self.ops = []
async def cookies(self):
return [{"name": "cf_clearance", "value": "secret-cookie"}]
async def clear_cookies(self):
self.ops.append("clear")
async def add_cookies(self, cookies):
self.ops.append(("add", cookies))
class FakePage:
def __init__(self, context):
self.context = context
async def screenshot(self, type):
return b"png"
async def inner_html(self, selector):
return "<body>page</body>"
async def evaluate(self, js):
return "UA/engine"
class FakeSession:
"""Вместо AsyncStealthySession: запоминает kwargs и вызовы, page_action выполняет на FakePage."""
instances: list["FakeSession"] = []
error: Exception | None = None
def __init__(self, **kwargs):
self.kwargs = kwargs
self.context = FakeContext()
self.calls = []
self.closed = False
FakeSession.instances.append(self)
async def start(self):
pass
async def close(self):
self.closed = True
async def fetch(self, url, page_action, **kwargs):
self.calls.append(("fetch", url))
if FakeSession.error:
raise FakeSession.error
return await page_action(FakePage(self.context))
async def run(self, page_action, **kwargs):
self.calls.append(("run", None))
return await page_action(FakePage(self.context))
def fake_execute(result=None, exc=None):
async def execute(*args, **kwargs):
if exc:
raise exc
return result if result is not None else []
return execute
@pytest.fixture
def env(monkeypatch, tmp_path):
FakeSession.instances, FakeSession.error = [], None
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
monkeypatch.setattr(actions, "execute", fake_execute())
with run_env(monkeypatch, tmp_path) as harness:
yield harness
+115
View File
@@ -0,0 +1,115 @@
"""api.actions.execute на поддельной странице: порядок, остановка на ошибке, антибот, длительность."""
import pytest
from antibot.orchestrator import ChallengeOutcome
from antibot.base import Stage
from api import actions
from api.schemas import ActionRequest
pytestmark = pytest.mark.anyio
class FakeElement:
def __init__(self, html):
self.html = html
self.hovered = False
async def inner_html(self):
return self.html
async def hover(self, **kwargs):
self.hovered = True
class FakePage:
def __init__(self, elements=None):
self.elements = elements or {}
self.filled = {}
async def query_selector(self, selector):
return self.elements.get(selector)
async def wait_for_selector(self, selector, timeout):
if selector not in self.elements:
raise TimeoutError(f"Timeout {timeout}ms exceeded")
async def fill(self, selector, value):
self.filled[selector] = value
@pytest.fixture
def challenge(monkeypatch):
"""Подмена прохождения антибота; outcome можно поменять в тесте."""
state = {"outcome": ChallengeOutcome(True, reason="antibot not requested"), "calls": 0}
async def fake(*args, **kwargs):
state["calls"] += 1
return state["outcome"]
monkeypatch.setattr(actions, "pass_challenges", fake)
return state
def acts(*items):
return [ActionRequest(**a) for a in items]
async def test_actions_run_in_order(challenge):
page = FakePage({"h1": FakeElement("Title"), "a": FakeElement("link")})
result = await actions.execute(page, acts(
{"type": "wait_for", "selector": "h1", "timeout": 1},
{"type": "fill", "selector": "#q", "value": "text"},
{"type": "mouse_move", "selector": "a"},
{"type": "inner_html", "selector": "h1"},
))
assert [(r["type"], r["ok"], r.get("result")) for r in result] == [
("wait_for", True, None), ("fill", True, None), ("mouse_move", True, None), ("inner_html", True, "Title"),
]
assert all(r["duration_ms"] >= 0 for r in result)
assert page.filled == {"#q": "text"}
assert page.elements["a"].hovered
async def test_stops_on_first_failure(challenge):
page = FakePage({"h1": FakeElement("Title")})
result = await actions.execute(page, acts(
{"type": "inner_html", "selector": "h1"},
{"type": "inner_html", "selector": "#missing"},
{"type": "inner_html", "selector": "h1"},
))
assert [r["ok"] for r in result] == [True, False]
assert result[1]["result"] == "selector not found: #missing"
@pytest.mark.parametrize("action", [
{"type": "mouse_move", "selector": "#missing"},
{"type": "inner_html", "selector": "#missing"},
])
async def test_selector_not_found(challenge, action):
result = await actions.execute(FakePage(), acts(action))
assert result == [{"type": action["type"], "ok": False, "result": "selector not found: #missing", "duration_ms": result[0]["duration_ms"]}]
async def test_wait_for_timeout_is_failure(challenge):
result = await actions.execute(FakePage(), acts({"type": "wait_for", "selector": "#x", "timeout": 2}))
assert result[0]["ok"] is False
assert "2000ms" in result[0]["result"]
async def test_empty_actions(challenge):
assert await actions.execute(FakePage(), []) == []
async def test_challenge_failed_skips_actions(challenge):
challenge["outcome"] = ChallengeOutcome(False, Stage.CHECKBOX, "cloudflare", "timeout")
page = FakePage()
with pytest.raises(actions.ChallengeFailed, match=r"cloudflare not passed: timeout \(stage: checkbox\)"):
await actions.execute(page, acts({"type": "fill", "selector": "#q", "value": "x"}), antibot_name="cloudflare")
assert page.filled == {}
async def test_pass_challenge_action(challenge):
challenge["outcome"] = ChallengeOutcome(True, reason="passed")
result = await actions.execute(FakePage(), acts({"type": "pass_challenge"}), antibot_name="cloudflare")
assert result[0]["ok"] and result[0]["result"] == "passed"
assert challenge["calls"] == 2
+65
View File
@@ -0,0 +1,65 @@
"""Настоящий воркер dramatiq на брокере в памяти: actor'ы, event loop AsyncIO, пул окон и heartbeat."""
import asyncio
import dramatiq
import fakeredis
import pytest
from api import actions
from tasks import store as task_store
from tasks.broker import COMMON_QUEUE, personal_queue, task_message
from worker import windows
from .conftest import FakeSession, fake_execute
@pytest.fixture
def worker(monkeypatch, tmp_path):
server = fakeredis.FakeServer()
monkeypatch.setattr(task_store, "connect", lambda url=None: fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
monkeypatch.setattr(windows, "USER_DATA_ROOT", tmp_path)
monkeypatch.setattr(actions, "execute", fake_execute())
FakeSession.instances, FakeSession.error = [], None
from db.engine import connect as connect_db
from worker import actors
monkeypatch.setattr(actors, "connect_db", lambda: connect_db(f"sqlite+aiosqlite:///{tmp_path / 'robotex.db'}"))
w = dramatiq.Worker(actors.broker, worker_timeout=100, worker_threads=2)
w.start()
try:
yield actors, server
finally:
w.stop()
# при остановке воркер закрывает окна и снимает heartbeat
assert all(s.closed for s in FakeSession.instances)
assert not fakeredis.FakeRedis(server=server).keys("robotex:worker:*")
SID = "11111111-1111-1111-1111-111111111111"
def submit(actors, server, queue, idle=0):
"""То же, что делает api: задача в Redis, сообщение в очередь; ждём, пока воркер её выполнит."""
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
asyncio.run(store.create("t1", SID, "https://example.com"))
actors.broker.enqueue(task_message("t1", SID, {"url": "https://example.com", "actions": []}, idle, queue))
actors.broker.join(queue)
return fakeredis.FakeRedis(server=server, decode_responses=True)
@pytest.mark.parametrize("queue", ["common", "personal"])
def test_task_executed_from_queue(worker, queue):
actors, server = worker
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue(actors.WORKER_ID))
task = redis.hgetall("robotex:task:t1")
assert task["status"] == "done", task
assert '"status": "ok"' in task["result"]
assert redis.exists(f"robotex:worker:{actors.WORKER_ID}")
def test_window_held_until_shutdown(worker):
actors, server = worker
redis = submit(actors, server, COMMON_QUEUE, idle=5)
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == actors.WORKER_ID
assert not FakeSession.instances[0].closed
+183
View File
@@ -0,0 +1,183 @@
"""API-ключи (#4) и журнал окон и запросов (#17)."""
import argparse
import asyncio
import uuid
import pytest
from sqlalchemy import select
from db import cli, clients
from db.engine import connect as connect_db
from db.models import Client, Request, Window
from .conftest import FakeSession
URL = "https://example.com"
def rows(env, model):
async def query():
async with env.db.session() as session:
return list(await session.scalars(select(model).order_by(model.id)))
return env.call(query)
def new_key(env, name="other"):
return env.call(clients.create_client, env.db, name)[1]
@pytest.mark.parametrize("request_", [
("post", "/v1/solve", {"url": URL, "actions": []}),
("post", "/v2/tasks", {"url": URL, "actions": []}),
("get", f"/v2/tasks/{uuid.uuid4()}", None),
("delete", "/v2/sessions/current", None),
])
@pytest.mark.parametrize("key", [None, "", "rbx_wrong"])
def test_requests_without_valid_key_rejected(env, request_, key):
method, path, body = request_
headers = {"X-API-Key": key} if key is not None else {}
env.client.headers.pop("X-API-Key")
resp = env.client.request(method, path, json=body, headers=headers)
assert resp.status_code == 401
assert env.messages == []
def test_health_is_open(env):
env.client.headers.pop("X-API-Key")
assert env.client.get("/health").status_code == 200
def test_revoked_key_rejected(env):
assert env.client.post("/v1/solve", json={"url": URL, "actions": []}).status_code == 200
assert env.call(clients.revoke, env.db, 1)
assert env.client.post("/v1/solve", json={"url": URL, "actions": []}).status_code == 401
def test_no_clients_means_no_access(env):
env.call(clients.revoke, env.db, 1)
assert [c.revoked_at is not None for c in rows(env, Client)] == [True]
assert env.client.post("/v2/tasks", json={"url": URL, "actions": []}).status_code == 401
def test_last_access_tracked(env):
assert rows(env, Client)[0].last_access_at is None
env.client.post("/v2/tasks", json={"url": URL, "actions": []})
assert rows(env, Client)[0].last_access_at is not None
def test_task_and_window_visible_only_to_their_client(env):
task_id = env.client.post("/v2/tasks", json={"url": URL, "actions": [], "browser_idle_timeout": 5}).json()["task_id"]
other = {"X-API-Key": new_key(env)}
# та же cookie сессии, но другой ключ: задача и окно — как чужие
assert env.client.get(f"/v2/tasks/{task_id}", headers=other).status_code == 404
assert env.client.post("/v2/tasks", json={"actions": []}, headers=other).status_code == 422
assert env.client.delete("/v2/sessions/current", headers=other).json() == {"closed": False}
assert env.pool.used == 1
assert env.client.get(f"/v2/tasks/{task_id}").json()["status"] == "done"
assert env.client.delete("/v2/sessions/current").json() == {"closed": True}
def test_journal_request_and_window(env):
env.client.post("/v2/tasks", json={
"url": URL, "proxy": "http://user:TopSecret1@host:3128", "captcha": "KEYSECRET2",
"actions": [{"type": "typing", "selector": "#p", "value": "PassSecret3"}],
})
env.client.post("/v1/solve", json={"url": URL, "actions": []})
requests = rows(env, Request)
assert [(r.api, r.status, r.client_id) for r in requests] == [("v2", "ok", 1), ("v1", "ok", 1)]
for r in requests:
assert r.created_at <= r.started_at <= r.finished_at
assert r.duration_ms is not None and r.window_id is not None
data = str(requests[0].task_data)
assert requests[0].task_data["url"] == URL
for secret in ("user", "TopSecret1", "KEYSECRET2", "PassSecret3"):
assert secret not in data
windows = rows(env, Window)
assert len(windows) == 2
assert all(w.closed_at is not None and w.started_at <= w.closed_at for w in windows)
assert [w.task_id for w in windows] == [r.task_id for r in requests]
def test_journal_held_window(env):
for body in ({"url": URL, "browser_idle_timeout": 5}, {"browser_idle_timeout": 5}):
env.client.post("/v2/tasks", json={"actions": [], **body})
window, = rows(env, Window)
assert window.closed_at is None and window.last_access_at > window.started_at
assert {r.window_id for r in rows(env, Request)} == {window.id}
env.client.delete("/v2/sessions/current")
assert rows(env, Window)[0].closed_at is not None
def test_journal_failed_request(env):
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
env.client.post("/v2/tasks", json={"url": URL, "actions": []})
request, = rows(env, Request)
assert (request.status, request.code) == ("err", "request_failed")
def test_journal_failure_does_not_break_task(env, monkeypatch):
class DownDatabase:
def session(self):
raise RuntimeError("db is down")
monkeypatch.setattr(env.pool.journal, "db", DownDatabase())
task_id = env.client.post("/v2/tasks", json={"url": URL, "actions": []}).json()["task_id"]
assert env.client.get(f"/v2/tasks/{task_id}").json()["result"]["status"] == "ok"
def test_cli_create_list_revoke(monkeypatch, tmp_path, capsys):
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{tmp_path / 'cli.db'}")
asyncio.run(cli.run(argparse.Namespace(command="create", name="acme")))
key = capsys.readouterr().out.strip().splitlines()[-1]
assert key.startswith(clients.KEY_PREFIX)
asyncio.run(cli.run(argparse.Namespace(command="list")))
listing = capsys.readouterr().out
assert "acme" in listing and "active" in listing and key not in listing
asyncio.run(cli.run(argparse.Namespace(command="revoke", id=1)))
assert capsys.readouterr().out.strip() == "revoked"
asyncio.run(cli.run(argparse.Namespace(command="stats", days=7)))
assert "запросов не было" in capsys.readouterr().out
async def check():
db = connect_db()
try:
return await clients.authenticate(db, key)
finally:
await db.close()
assert asyncio.run(check()) is None
def test_usage_stats(env, monkeypatch):
from api import actions as api_actions
from db import stats
from .conftest import fake_execute
env.client.post("/v2/tasks", json={"url": URL, "actions": [], "browser_idle_timeout": 5})
env.client.post("/v2/tasks", json={"actions": []}) # в открытом окне, после неё окно закрывается
monkeypatch.setattr(api_actions, "execute", fake_execute(exc=api_actions.ChallengeFailed("cf")))
env.client.post("/v1/solve", json={"url": URL, "actions": []})
other = {"X-API-Key": new_key(env, "acme")}
env.client.cookies.clear()
monkeypatch.setattr(api_actions, "execute", fake_execute())
env.client.post("/v1/solve", json={"url": URL, "actions": []}, headers=other)
usage = {u.name: u for u in env.call(stats.usage, env.db, stats.since_days(1))}
test, acme = usage["test"], usage["acme"]
assert (test.requests, test.ok, test.errors, test.windows) == (3, 2, {"challenge_failed": 1}, 2)
assert (acme.requests, acme.ok, acme.windows) == (1, 1, 1)
assert test.avg_ms is not None and test.p95_ms is not None
assert test.browser_seconds > 0
# все окна закрыты: время браузера больше не растёт
again = {u.name: u.browser_seconds for u in env.call(stats.usage, env.db, stats.since_days(1))}
assert again == {name: u.browser_seconds for name, u in usage.items()}
# период фильтрует запросы и окна
assert env.call(stats.usage, env.db, stats.since_days(-1)) == []
+163
View File
@@ -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())
+90
View File
@@ -0,0 +1,90 @@
"""Цикл detect → solve → verify в antibot.orchestrator на поддельном провайдере."""
import pytest
from patchright.async_api import Error as PlaywrightError
from antibot import orchestrator
from antibot.base import ChallengeInfo, Stage
from antibot.exceptions import ProviderNotDetected
pytestmark = pytest.mark.anyio
class FakePage:
def __init__(self):
self.waits = []
async def wait_for_load_state(self, state):
pass
async def wait_for_timeout(self, ms):
self.waits.append(ms)
class FakeProvider:
provider = "fake"
def __init__(self, detects, solve_error=None):
self.detects = list(detects) # что вернуть/бросить на каждый detect
self.solve_error = solve_error
self.solves = 0
async def detect(self, page):
item = self.detects.pop(0) if len(self.detects) > 1 else self.detects[0]
if isinstance(item, Exception):
raise item
return item
async def solve(self, page, info):
self.solves += 1
if self.solve_error:
raise self.solve_error
def info(success, stage=Stage.CHECKBOX):
return ChallengeInfo(stage=stage, provider="fake", success=success, meta={"reason": "done" if success else None})
@pytest.fixture
def use_provider(monkeypatch):
def install(provider):
async def select_one(page, name, **kwargs):
return provider
monkeypatch.setattr(orchestrator, "select_one", select_one)
return provider
return install
async def test_not_requested():
outcome = await orchestrator.pass_challenges(None, FakePage())
assert outcome.cleared and outcome.reason == "antibot not requested"
async def test_solved_after_one_step(use_provider):
provider = use_provider(FakeProvider([info(False), info(True)]))
outcome = await orchestrator.pass_challenges("fake", FakePage())
assert outcome.cleared and outcome.reason == "done"
assert provider.solves == 1
async def test_detect_survives_navigation(use_provider):
"""Челлендж уходит на целевую страницу посреди detect — повторяем detect, а не падаем."""
destroyed = PlaywrightError("Page.query_selector: Execution context was destroyed, most likely because of a navigation.")
provider = use_provider(FakeProvider([destroyed, info(True)]))
outcome = await orchestrator.pass_challenges("fake", FakePage())
assert outcome.cleared
assert provider.solves == 0
async def test_failing_solve_pauses_and_times_out(use_provider):
page = FakePage()
use_provider(FakeProvider([info(False)], solve_error=RuntimeError("click failed")))
outcome = await orchestrator.pass_challenges("fake", page, timeout_ms=50)
assert not outcome.cleared
assert outcome.reason == "timeout"
assert outcome.last_stage == Stage.CHECKBOX
assert page.waits and set(page.waits) == {orchestrator.SOLVE_RETRY_DELAY_MS}
async def test_unknown_provider():
with pytest.raises(ProviderNotDetected, match="unknown captcha_type 'datadome'"):
await orchestrator.pass_challenges("datadome", FakePage())
+71
View File
@@ -0,0 +1,71 @@
import json
from api import profile
def test_fingerprint_generated_once(tmp_path):
first = profile.load_or_create(tmp_path)
assert profile.load_or_create(tmp_path) == first
assert json.loads((tmp_path / profile.SETTINGS_FILE).read_text()) == first
screen = first["fingerprint"]["screen"]
assert screen["width"] >= profile.MIN_SCREEN[0] and screen["height"] >= profile.MIN_SCREEN[1]
assert first["fingerprint"]["device_scale_factor"] >= 1
assert first["fingerprint"]["canvas_seed"] > 0
assert "user_agent" not in first
def test_old_profile_settings_get_fingerprint(tmp_path):
"""Профиль из версии, где сохранялся user_agent клиента: UA отбрасывается, отпечаток создаётся."""
(tmp_path / profile.SETTINGS_FILE).write_text(json.dumps({"user_agent": "First/1"}))
settings = profile.load_or_create(tmp_path)
assert set(settings) == {"fingerprint"}
def test_profiles_get_independent_fingerprints(tmp_path):
"""Отпечатки генерируются независимо: из выборки профилей получается больше одного варианта."""
variants = set()
for i in range(30):
d = tmp_path / str(i)
d.mkdir()
fp = profile.load_or_create(d)["fingerprint"]
variants.add((fp["screen"]["width"], fp["screen"]["height"], fp["device_scale_factor"]))
assert len(variants) > 1
def test_generate_falls_back_when_browserforge_fails(monkeypatch):
class Failing:
def generate(self, **kwargs):
raise ValueError("Cannot generate headers")
monkeypatch.setattr(profile, "FingerprintGenerator", Failing)
fp = profile.generate_fingerprint(attempts=2)
assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 1920, "height": 1080}, 1)
assert fp["canvas_seed"] > 0
def test_browser_kwargs():
kwargs = profile.browser_kwargs({"fingerprint": {
"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25, "canvas_seed": 12345,
}})
assert kwargs["additional_args"] == {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}
assert "useragent" not in kwargs
script = kwargs["init_script_content"]
assert "const SEED = 12345 | 0;" in script and "__SEED__" not in script
def test_profile_before_canvas_noise_gets_seed_and_keeps_screen(tmp_path):
old = {"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 2}}
(tmp_path / profile.SETTINGS_FILE).write_text(json.dumps(old))
fp = profile.load_or_create(tmp_path)["fingerprint"]
assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 2560, "height": 1440}, 2)
assert fp["canvas_seed"] > 0
assert profile.load_or_create(tmp_path)["fingerprint"] == fp
def test_profiles_get_different_canvas_seeds(tmp_path):
seeds = set()
for i in range(20):
(tmp_path / str(i)).mkdir()
seeds.add(profile.load_or_create(tmp_path / str(i))["fingerprint"]["canvas_seed"])
assert len(seeds) == 20
+83
View File
@@ -0,0 +1,83 @@
import pytest
from pydantic import ValidationError
from api.schemas import ActionRequest, SolveRequest, WAIT_FOR_DEFAULT_TIMEOUT
def solve(**kwargs) -> SolveRequest:
return SolveRequest(**{"url": "https://example.com", "actions": [], **kwargs})
@pytest.mark.parametrize("action, error", [
({"type": "nope"}, "unknown action type"),
({"type": "wait_for"}, "requires: selector"),
({"type": "fill", "selector": "#q"}, "requires: value"),
({"type": "fill", "selector": "#q", "value": ""}, "requires: value"),
({"type": "typing", "selector": "#q"}, "requires: value"),
({"type": "click"}, "requires: selector"),
({"type": "delay"}, "requires: value"),
({"type": "delay", "value": "abc"}, "integer"),
({"type": "delay", "value": "601"}, "between 0 and 600"),
({"type": "wait_for", "selector": "a", "timeout": 0}, "between 1 and 7200"),
({"type": "wait_for", "selector": "a", "timeout": 7201}, "between 1 and 7200"),
({"type": "captcha", "selector": "#c", "value": "datadome"}, "captcha value must be one of"),
])
def test_invalid_action(action, error):
with pytest.raises(ValidationError, match=error):
ActionRequest(**action)
@pytest.mark.parametrize("action", [
{"type": "wait_for", "selector": "a", "timeout": 10},
{"type": "fill", "selector": "#q", "value": "x"},
{"type": "typing", "selector": "#q", "value": "Привет"},
{"type": "inner_text", "selector": "h1"},
{"type": "delay", "value": "0"},
{"type": "pass_challenge"},
{"type": "captcha", "selector": "#c", "value": "hcaptcha"},
{"type": "inner_html", "selector": "h1"},
])
def test_valid_action(action):
assert ActionRequest(**action).type == action["type"]
def test_wait_for_default_timeout():
assert ActionRequest(type="wait_for", selector="a").timeout == WAIT_FOR_DEFAULT_TIMEOUT
@pytest.mark.parametrize("proxy", [
"http://host:8080",
"https://user:pass@host:443",
"socks5://10.0.0.1:1080",
])
def test_valid_proxy(proxy):
assert solve(proxy=proxy).proxy == proxy
@pytest.mark.parametrize("proxy, error", [
("host:8080", "scheme"),
("ftp://host:21", "scheme"),
("http://host", "host:port"),
("http://:8080", "host:port"),
("http://host:99999", "invalid proxy url"),
])
def test_invalid_proxy(proxy, error):
with pytest.raises(ValidationError, match=error):
solve(proxy=proxy)
def test_proxy_optional():
assert solve().proxy is None
assert solve(proxy="").proxy is None
def test_user_agent_rejected():
with pytest.raises(ValidationError, match="user_agent is not supported"):
solve(user_agent="Mozilla/5.0")
def test_captcha_action_requires_key():
action = {"type": "captcha", "selector": "#c", "value": "recaptcha"}
with pytest.raises(ValidationError, match="requires request field 'captcha'"):
solve(actions=[action])
assert solve(actions=[action], captcha="key").captcha == "key"
+74
View File
@@ -0,0 +1,74 @@
import fakeredis
import pytest
from tasks import store as task_store
from tasks.store import Status, TaskStore
pytestmark = pytest.mark.anyio
@pytest.fixture
def store():
return TaskStore(fakeredis.aioredis.FakeRedis(decode_responses=True))
async def test_claim_once(store):
await store.create("t", "s", "https://example.com")
await store.heartbeat("w1", {})
assert (await store.get("t"))["status"] == Status.QUEUED
assert await store.claim("t", "w1")
assert not await store.claim("t", "w2")
assert not await store.cancel("t")
async def test_cancelled_task_is_not_claimed(store):
await store.create("t", "s", None)
assert await store.cancel("t")
assert not await store.claim("t", "w1")
assert (await store.get("t"))["status"] == Status.CANCELLED
async def test_missing_task_is_not_claimed(store):
assert not await store.claim("nope", "w1")
async def test_redelivery_while_running_here_is_skipped(store):
await store.create("t", "s", None)
await store.heartbeat("w1", {})
assert await store.claim("t", "w1")
assert not await store.claim("t", "w1", running_here=True)
async def test_task_of_dead_worker_is_reclaimed_limited_times(store):
await store.create("t", "s", None)
await store.heartbeat("w2", {})
assert await store.claim("t", "w1") # w1 умер — heartbeat нет
assert await store.claim("t", "w2") # attempts = 2
await store.remove_worker("w2")
assert not await store.claim("t", "w3")
task = await store.get("t")
assert task["status"] == Status.FAILED and "worker died" in task["error"]
async def test_task_of_live_worker_is_not_reclaimed(store):
await store.create("t", "s", None)
await store.heartbeat("w1", {})
assert await store.claim("t", "w1")
assert not await store.claim("t", "w2")
async def test_finish_sets_result_and_ttl(store):
await store.create("t", "s", None)
assert await store.redis.ttl("robotex:task:t") == task_store.PENDING_TTL
await store.finish("t", {"status": "ok", "actions": []})
task = await store.get("t")
assert task["status"] == Status.DONE and task["result"] == {"status": "ok", "actions": []}
assert await store.redis.ttl("robotex:task:t") == task_store.RESULT_TTL
async def test_live_window_requires_live_worker(store):
await store.set_session("s", "w1", 5, 300, client_id=7)
assert await store.live_window("s") is None
await store.heartbeat("w1", {"browser_slots": 1, "free_slots": 0})
assert await store.live_window("s") == {"worker_id": "w1", "idle_timeout": "5", "client_id": "7"}
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
+203
View File
@@ -0,0 +1,203 @@
"""/v1/solve поверх очереди: контракт v1 не изменился (#84)."""
import logging
import uuid
import pytest
import main
from api import actions
from worker import windows
from worker.windows import Outcome
from .conftest import FakeSession, fake_execute
def post(env, **body):
return env.client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
def test_ok(env, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
resp = post(env, html=True, screenshot=True)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "code" not in data and "error" not in data
assert data["user_agent"] == "UA/engine"
assert data["actions"] == [{"type": "wait_for", "ok": True, "duration_ms": 5}]
assert data["html"] == "<body>page</body>"
assert data["screenshot"] == "cG5n" # base64(b"png")
assert data["duration_ms"] >= 0
assert uuid.UUID(resp.cookies["session_id"])
def test_html_and_screenshot_only_on_request(env):
data = post(env).json()
assert "html" not in data and "screenshot" not in data
assert data["actions"] == []
def test_action_failed(env, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([
{"type": "click", "ok": True, "duration_ms": 1},
{"type": "inner_html", "ok": False, "result": "selector not found: #x", "duration_ms": 1},
]))
data = post(env, screenshot=True).json()
assert data["status"] == "err"
assert data["code"] == "action_failed"
assert data["error"] == "inner_html: selector not found: #x"
assert data["screenshot"]
def test_challenge_failed(env, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
data = post(env, screenshot=True).json()
assert data["status"] == "err"
assert data["code"] == "challenge_failed"
assert data["error"] == "cloudflare not passed: timeout"
assert data["actions"] == []
assert data["cookies"] and data["screenshot"]
def test_request_failed(env):
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
data = post(env).json()
assert data == {
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
"actions": [], "duration_ms": data["duration_ms"],
}
@pytest.mark.parametrize("workers", ["busy", "none"])
def test_no_slots(env, workers):
if workers == "busy":
env.heartbeat(free_slots=0)
else:
env.redis.flushall()
resp = post(env)
assert resp.status_code == 503
assert resp.headers["retry-after"] == main.RETRY_AFTER
assert resp.json() == {"status": "err", "code": "no_slots", "error": "No slots available"}
assert env.messages == []
def test_not_picked_up_in_time_is_503_and_cancelled(env, monkeypatch):
env.auto = False
monkeypatch.setattr(main, "V1_QUEUE_WAIT", 0)
resp = post(env)
assert resp.status_code == 503
assert resp.json()["code"] == "no_slots"
# воркер позже достаёт сообщение — отменённую задачу не выполняет
env.deliver_pending()
assert env.outcomes == [Outcome.SKIPPED]
assert FakeSession.instances == []
def test_slot_released_after_request(env):
FakeSession.error = RuntimeError("boom")
for _ in range(3):
assert post(env).status_code == 200
assert env.pool.used == 0 and env.pool.windows == {}
assert all(s.closed for s in FakeSession.instances)
def test_invalid_request_is_422_without_task(env):
resp = post(env, actions=[{"type": "nope"}])
assert resp.status_code == 422
assert env.messages == []
def test_session_cookie_reused(env):
sid = str(uuid.uuid4())
env.client.cookies.set("session_id", sid)
resp = post(env)
assert resp.cookies["session_id"] == sid
assert FakeSession.instances[0].kwargs["user_data_dir"] == str(env.profiles / sid)
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
def test_bad_session_cookie_gets_new_session(env, bad):
env.client.cookies.set("session_id", bad)
resp = post(env)
sid = resp.cookies["session_id"]
assert sid != bad and uuid.UUID(sid)
assert [p.name for p in env.profiles.iterdir()] == [sid]
def test_user_agent_param_rejected(env):
resp = post(env, user_agent="First/1")
assert resp.status_code == 422
assert "take user_agent from the response" in resp.text
assert env.messages == []
def test_fingerprint_passed_to_browser_and_ua_untouched(env):
post(env)
kwargs = FakeSession.instances[0].kwargs
assert "useragent" not in kwargs
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
def test_cookies_replace_profile_cookies_only_when_passed(env):
post(env)
post(env, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
post(env, cookies=[])
assert [s.kwargs["clear_cookies"] for s in FakeSession.instances] == [False, True, True]
def test_proxy_passed_and_geo_resolved(env, monkeypatch):
seen = {}
async def fake_geo(proxy):
seen["proxy"] = proxy
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
monkeypatch.setattr(windows, "resolve_geo", fake_geo)
post(env, proxy="http://u:p@host:3128")
kwargs = FakeSession.instances[0].kwargs
assert kwargs["proxy"] == {"server": "http://host:3128", "username": "u", "password": "p"}
assert seen["proxy"] == kwargs["proxy"]
assert (kwargs["locale"], kwargs["timezone_id"]) == ("de-DE", "Europe/Berlin")
def test_without_proxy_no_geo(env, monkeypatch):
async def fail_geo(proxy):
raise AssertionError("geo must not be resolved without proxy")
monkeypatch.setattr(windows, "resolve_geo", fail_geo)
post(env)
assert FakeSession.instances[0].kwargs["proxy"] is None
def test_secrets_not_logged_nor_stored(env, monkeypatch, caplog):
async def no_geo(proxy):
return None
monkeypatch.setattr(windows, "resolve_geo", no_geo)
caplog.set_level(logging.DEBUG)
post(
env, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True,
cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}],
actions=[
{"type": "fill", "selector": "#p", "value": "PassSecret4"},
{"type": "typing", "selector": "#p", "value": "PassSecret5"},
],
)
log = caplog.text
assert "Incoming payload" in log and "Outgoing payload" in log
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "PassSecret5", "secret-cookie", "<body>page"):
assert secret not in log
# в Redis — только результат (там cookies и html страницы), но не секреты запроса
stored = " ".join(str(env.redis.hgetall(k)) for k in env.redis.keys("*task*"))
for secret in ("TopSecret1", "KEYSECRET2", "PassSecret4"):
assert secret not in stored
def test_health(env):
data = env.client.get("/health").json()
assert data["status"] == "ok"
assert (data["browser_slots"], data["free_slots"]) == (2, 2)
assert [w["worker_id"] for w in data["workers"]] == ["w1"]
+212
View File
@@ -0,0 +1,212 @@
"""/v2: задачи через очередь, доступ по cookie сессии, удержание окна и маршрутизация в него (#80–#83)."""
import uuid
import pytest
from tasks import store as task_store
from tasks.broker import COMMON_QUEUE, personal_queue
from worker.windows import Outcome
from .conftest import FakeSession, WORKER_ID
def create(env, **body):
return env.client.post("/v2/tasks", json={"actions": [], **body})
def get(env, task_id):
return env.client.get(f"/v2/tasks/{task_id}")
def session_key(sid):
return f"{task_store.PREFIX}:session:{sid}"
def test_create_and_get_result(env):
resp = create(env, url="https://example.com", html=True)
assert resp.status_code == 202
body = resp.json()
assert body["status"] == "queued" and body["url"] == f"/v2/tasks/{body['task_id']}"
assert env.messages[0].queue_name == COMMON_QUEUE
task = get(env, body["task_id"]).json()
assert task["status"] == "done"
assert task["result"]["status"] == "ok"
assert task["result"]["html"] == "<body>page</body>"
assert task["result"]["user_agent"] == "UA/engine"
def test_queued_until_worker_takes_it(env):
env.auto = False
task_id = create(env, url="https://example.com").json()["task_id"]
assert get(env, task_id).json() == {"task_id": task_id, "status": "queued"}
env.deliver_pending()
assert get(env, task_id).json()["status"] == "done"
@pytest.mark.parametrize("cookie", [None, "other", "garbage"])
def test_task_visible_only_to_its_session(env, cookie):
task_id = create(env, url="https://example.com").json()["task_id"]
env.client.cookies.clear()
if cookie:
env.client.cookies.set("session_id", str(uuid.uuid4()) if cookie == "other" else cookie)
assert get(env, task_id).status_code == 404
@pytest.mark.parametrize("task_id", [str(uuid.uuid4()), "../etc", "x"])
def test_unknown_task_404(env, task_id):
create(env, url="https://example.com")
assert get(env, task_id).status_code == 404
def test_result_ttl(env):
task_id = create(env, url="https://example.com").json()["task_id"]
ttl = env.redis.ttl(f"{task_store.PREFIX}:task:{task_id}")
assert task_store.RESULT_TTL - 5 < ttl <= task_store.RESULT_TTL
def test_url_required_without_window(env):
resp = create(env)
assert resp.status_code == 422
assert "no open browser window" in resp.text
assert env.messages == []
@pytest.mark.parametrize("value", [-1, 121])
def test_idle_timeout_range(env, value):
assert create(env, url="https://example.com", browser_idle_timeout=value).status_code == 422
def test_window_closed_by_default(env):
create(env, url="https://example.com")
sid = env.client.cookies["session_id"]
assert env.pool.windows == {} and env.pool.used == 0
assert FakeSession.instances[0].closed
assert not env.redis.exists(session_key(sid))
def test_window_held_and_reused(env):
create(env, url="https://example.com", browser_idle_timeout=5)
sid = env.client.cookies["session_id"]
engine = FakeSession.instances[0]
assert not engine.closed and env.pool.used == 1
window = env.redis.hgetall(session_key(sid))
assert (window["worker_id"], window["idle_timeout"], window["client_id"]) == (WORKER_ID, "5", "1")
assert 290 < env.redis.ttl(session_key(sid)) <= 300
# следующая задача сессии — в персональную очередь воркера с окном, без url — на текущей странице
task_id = create(env, browser_idle_timeout=2).json()["task_id"]
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
assert get(env, task_id).json()["result"]["status"] == "ok"
assert FakeSession.instances == [engine]
assert engine.calls == [("fetch", "https://example.com"), ("run", None)]
# простой продлён значением из последней задачи
assert 110 < env.redis.ttl(session_key(sid)) <= 120
# задача с browser_idle_timeout=0 закрывает окно после себя
create(env, url="https://example.com/next")
assert engine.closed and env.pool.used == 0
assert not env.redis.exists(session_key(sid))
def test_cookies_replace_in_open_window(env):
create(env, url="https://example.com", browser_idle_timeout=5)
create(env, browser_idle_timeout=5, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
create(env, browser_idle_timeout=5)
assert FakeSession.instances[0].context.ops == [
"clear", ("add", [{"name": "a", "value": "1", "url": "https://example.com"}]),
]
def test_proxy_change_restarts_window(env):
create(env, url="https://example.com", browser_idle_timeout=5)
create(env, url="https://example.com", browser_idle_timeout=5, proxy="http://host:3128")
first, second = FakeSession.instances
assert first.closed and not second.closed
assert second.kwargs["proxy"] == {"server": "http://host:3128"}
assert env.pool.used == 1
def test_failure_in_window_closes_it(env):
create(env, url="https://example.com", browser_idle_timeout=5)
sid = env.client.cookies["session_id"]
FakeSession.error = RuntimeError("net::ERR_TIMED_OUT")
task_id = create(env, url="https://example.com", browser_idle_timeout=5).json()["task_id"]
assert get(env, task_id).json()["result"]["code"] == "request_failed"
assert FakeSession.instances[0].closed and env.pool.used == 0
assert not env.redis.exists(session_key(sid))
def test_idle_window_expires(env):
create(env, url="https://example.com", browser_idle_timeout=5)
sid = env.client.cookies["session_id"]
env.call(env.pool.expire_idle)
assert env.pool.used == 1
env.redis.delete(session_key(sid)) # TTL истёк
env.call(env.pool.expire_idle)
assert env.pool.used == 0 and FakeSession.instances[0].closed
def test_delete_session_closes_window(env):
create(env, url="https://example.com", browser_idle_timeout=5)
sid = env.client.cookies["session_id"]
assert env.client.delete("/v2/sessions/current").json() == {"closed": True}
assert env.messages[-1].actor_name == "close_window"
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
assert FakeSession.instances[0].closed and env.pool.used == 0
assert not env.redis.exists(session_key(sid))
assert env.client.delete("/v2/sessions/current").json() == {"closed": False}
def test_open_windows_take_slots(env):
for _ in range(2):
env.client.cookies.clear()
create(env, url="https://example.com", browser_idle_timeout=5)
assert env.pool.used == 2
env.client.cookies.clear()
create(env, url="https://example.com")
assert env.outcomes[-1] == Outcome.NO_SLOT
# /v1/solve без окна при занятых слотах — сразу 503, как раньше
env.client.cookies.clear()
assert env.client.post("/v1/solve", json={"url": "https://example.com", "actions": []}).status_code == 503
def test_dead_worker_window_goes_to_common_queue(env):
create(env, url="https://example.com", browser_idle_timeout=5)
env.redis.delete(f"{task_store.PREFIX}:worker:{WORKER_ID}")
env.auto = False
create(env, url="https://example.com")
assert env.messages[-1].queue_name == COMMON_QUEUE
def test_old_profiles_cleaned_up(env):
"""#20: профили старше PROFILE_TTL_DAYS удаляются, кроме тех, где открыто окно."""
import os
import time
from worker.windows import LAST_USED_FILE
create(env, url="https://example.com", browser_idle_timeout=5)
held = env.client.cookies["session_id"]
elsewhere, old, fresh = (str(uuid.uuid4()) for _ in range(3))
env.redis.hset(session_key(elsewhere), mapping={"worker_id": "w2", "idle_timeout": 5})
long_ago = time.time() - 8 * 86400
for sid in (held, elsewhere, old, fresh, "not-a-session"):
(env.profiles / sid).mkdir(exist_ok=True)
marker = env.profiles / sid / LAST_USED_FILE
marker.touch()
if sid != fresh:
os.utime(marker, (long_ago, long_ago))
assert env.call(env.pool.cleanup_profiles) == [old]
assert sorted(p.name for p in env.profiles.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"])
def test_profile_marked_as_used(env):
from worker.windows import LAST_USED_FILE
create(env, url="https://example.com")
assert (env.profiles / env.client.cookies["session_id"] / LAST_USED_FILE).exists()