Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d616e1f4f | ||
|
|
0e10fb671d | ||
|
|
4d90dc4a21 | ||
|
|
fdee12e2d8 | ||
|
|
999644883f | ||
|
|
8ccf5b6751 | ||
|
|
cb85e5f3ab | ||
|
|
11ba83140d | ||
|
|
1a4bd8d672 | ||
|
|
9dc2c0b619 | ||
|
|
4c8394eb4c | ||
|
|
1075267d27 | ||
|
|
49a2f3024a | ||
|
|
6ce8a8c648 | ||
|
|
7b511d5d38 | ||
|
|
fe509c25c3 | ||
|
|
0ae5b0c83f | ||
|
|
58100fa45d |
No files matched your search
+1
-1
@@ -12,5 +12,5 @@ tab_width = 4
|
||||
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}]
|
||||
indent_size = 2
|
||||
|
||||
[{*.yaml,*.yml,environment.yaml,environment.yml}]
|
||||
[{*.yaml,*.yml,*.http,environment.yaml,environment.yml}]
|
||||
indent_size = 2
|
||||
@@ -6,3 +6,7 @@ wheels/
|
||||
docs/*.bkp
|
||||
docs/*.dtmp
|
||||
build/
|
||||
client/dist/
|
||||
client/build/
|
||||
client/src/*.egg-info/
|
||||
http-client.private.env.json
|
||||
+4
-3
@@ -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))}"]
|
||||
@@ -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"]
|
||||
@@ -1,14 +1,40 @@
|
||||
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 build release
|
||||
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 dev dev-deps dev-key
|
||||
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv build release
|
||||
.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 dev dev-deps dev-key
|
||||
|
||||
# образы сервисов: 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 ?=
|
||||
|
||||
# make dev (#90): rabbitmq, redis, postgres — в Docker с портами на 127.0.0.1 (docker-compose.dev.yml), api и worker —
|
||||
# на хосте, Chrome открывается на рабочем столе. Порты нестандартные: 5672/5432/8000 часто заняты другими проектами.
|
||||
DEV_COMPOSE := docker compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
DEV_API_PORT ?= 8010
|
||||
DEV_RABBITMQ_PORT ?= 5673
|
||||
DEV_REDIS_PORT ?= 6380
|
||||
DEV_POSTGRES_PORT ?= 5433
|
||||
# окон Chrome на рабочем столе; потоков dramatiq — вдвое больше, как в Dockerfile
|
||||
DEV_SLOTS ?= 2
|
||||
RABBITMQ_PASSWORD ?= robotex
|
||||
POSTGRES_PASSWORD ?= robotex
|
||||
export DEV_RABBITMQ_PORT DEV_REDIS_PORT DEV_POSTGRES_PORT RABBITMQ_PASSWORD POSTGRES_PASSWORD
|
||||
DEV_ENV := RABBITMQ_URL=amqp://robotex:$(RABBITMQ_PASSWORD)@localhost:$(DEV_RABBITMQ_PORT)/ \
|
||||
REDIS_URL=redis://localhost:$(DEV_REDIS_PORT)/0 \
|
||||
DATABASE_URL=postgresql+asyncpg://robotex:$(POSTGRES_PASSWORD)@localhost:$(DEV_POSTGRES_PORT)/robotex \
|
||||
BROWSER_SLOTS=$(DEV_SLOTS) \
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
help:
|
||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||
@@ -17,8 +43,24 @@ help:
|
||||
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
|
||||
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
||||
@echo "make clean - удалить $(WHEELS_DIR)/"
|
||||
@echo "make build [ver=latest] - собрать docker-образ robotex:<ver>"
|
||||
@echo "make release ver=<ver> - собрать образ и пакет для деплоя в build/<ver>/ (compose, .env, образ .tar.gz)"
|
||||
@echo "make build [ver=latest] - собрать образы $(API_IMAGE):<ver> и $(WORKER_IMAGE):<ver>"
|
||||
@echo "make push ver=<ver> - собрать образы и отправить в $(REGISTRY)"
|
||||
@echo "make release ver=<ver> [out=registry|file] - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env);"
|
||||
@echo " образы: registry — push в $(REGISTRY) (по умолчанию), file — архив .tar.gz"
|
||||
@echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis"
|
||||
@echo "make down - остановить локальный стенд"
|
||||
@echo "make logs - логи api и worker"
|
||||
@echo "make dev - api и worker на хосте с видимым Chrome, rabbitmq/redis/postgres в Docker; API на :$(DEV_API_PORT)"
|
||||
@echo "make dev-key name=<клиент> - выпустить API-ключ клиенту при make dev"
|
||||
@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)
|
||||
@@ -50,32 +92,115 @@ clean:
|
||||
rm -rf $(WHEELS_DIR)
|
||||
|
||||
build:
|
||||
@echo "build an application"
|
||||
docker build -t robotex:$(ver) .
|
||||
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
|
||||
|
||||
# контейнерные api и worker останавливаются: иначе worker в Docker тоже берёт задачи из общей очереди и выполняет их невидимо
|
||||
dev-deps:
|
||||
-docker compose stop api worker
|
||||
$(DEV_COMPOSE) up -d --wait rabbitmq redis postgres
|
||||
|
||||
# Ctrl+C останавливает оба процесса; инфраструктура остаётся запущенной (make down — остановить)
|
||||
dev: dev-deps
|
||||
cd src && export $(DEV_ENV) && \
|
||||
{ $(PYTHON) -m uvicorn main:app --reload --port $(DEV_API_PORT) & api=$$!; \
|
||||
trap 'kill $$api 2>/dev/null' EXIT INT TERM; \
|
||||
$(PYTHON) -m dramatiq worker.actors --processes 1 --threads $$(( $(DEV_SLOTS) * 2 )); }
|
||||
|
||||
dev-key: dev-deps
|
||||
cd src && $(DEV_ENV) $(PYTHON) -m db.cli create "$(name)"
|
||||
|
||||
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)
|
||||
# куда деваются образы при make release: registry — push в $(REGISTRY), file — архив .tar.gz в $(RELEASE_DIR)/
|
||||
out ?= registry
|
||||
|
||||
ifneq ($(filter release,$(MAKECMDGOALS)),)
|
||||
ifneq ($(filter release push,$(MAKECMDGOALS)),)
|
||||
ifeq ($(ver),latest)
|
||||
$(error Укажите версию: make release ver=v0.0.2)
|
||||
endif
|
||||
ifeq ($(filter $(out),registry file),)
|
||||
$(error out=registry или out=file, а не "$(out)")
|
||||
endif
|
||||
endif
|
||||
|
||||
release: build
|
||||
@echo "make release $(ver) -> $(RELEASE_DIR)/"
|
||||
ifeq ($(out),file)
|
||||
RELEASE_RUN := docker load -i robotex-$(ver).tar.gz
|
||||
else
|
||||
RELEASE_RUN := docker compose pull
|
||||
endif
|
||||
|
||||
release: $(if $(filter file,$(out)),build,push)
|
||||
@echo "make release $(ver) [out=$(out)] -> $(RELEASE_DIR)/"
|
||||
@mkdir -p $(RELEASE_DIR)
|
||||
sed 's|image: robotex:.*|image: robotex:$(ver)|' docker-compose.prod.yml > $(RELEASE_DIR)/docker-compose.yml
|
||||
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
|
||||
docker save robotex:$(ver) | gzip > $(RELEASE_DIR)/robotex-$(ver).tar.gz
|
||||
ifeq ($(out),file)
|
||||
# сторонние образы (rabbitmq, redis, postgres) кладутся в архив; при out=registry сервер берёт их с Docker Hub
|
||||
for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done
|
||||
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
|
||||
endif
|
||||
@printf '%s\n' \
|
||||
'# Robotex $(ver)' \
|
||||
'' \
|
||||
'```bash' \
|
||||
'docker load -i robotex-$(ver).tar.gz' \
|
||||
'$(RELEASE_RUN)' \
|
||||
'docker compose up -d' \
|
||||
'```' \
|
||||
'' \
|
||||
'Настройки (порт, CPU, память) — в .env.' > $(RELEASE_DIR)/README.md
|
||||
'Настройки (порт, слоты, CPU и память воркера, пароли RabbitMQ и Postgres) — в .env.' \
|
||||
'Ключ клиенту: docker compose exec api python -m db.cli create <имя>' > $(RELEASE_DIR)/README.md
|
||||
@echo "Готово: $(RELEASE_DIR)/"
|
||||
|
||||
# позволяет передавать имя пакета как аргумент: make install redis
|
||||
|
||||
@@ -6,54 +6,73 @@ behind Cloudflare-style JS challenges.
|
||||
|
||||
## Status
|
||||
|
||||
Early MVP (v0), under active development. Single browser engine, no queue, no billing yet. Tasks and project wiki —
|
||||
Early MVP (v0), under active development. Single browser engine, no billing yet. Tasks and project wiki —
|
||||
[Redmine](https://redmine.sokolab.xyz/projects/robotex).
|
||||
|
||||
Not implemented yet, though planned for v1:
|
||||
|
||||
- API-key auth (`X-API-Key` is accepted but **not checked**) — [#4](https://redmine.sokolab.xyz/issues/4)
|
||||
- persisting request outcomes (success rate) — [#17](https://redmine.sokolab.xyz/issues/17)
|
||||
- cleanup of old browser profiles — [#20](https://redmine.sokolab.xyz/issues/20)
|
||||
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, on the host) → FastAPI POST /v1/solve → browser slot (semaphore, 503 if none)
|
||||
│
|
||||
▼
|
||||
Chrome (Patchright), profile per session_id
|
||||
pass anti-bot → run actions → collect result
|
||||
│
|
||||
▼
|
||||
release slot → 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. Browser session wrapper ported from scrapling. Firefox is not
|
||||
supported yet.
|
||||
- **API**: FastAPI, single versioned endpoint (`POST /v1/solve`) plus `GET /health`.
|
||||
- **Concurrency**: bounded by an `asyncio.Semaphore` of `BROWSER_SLOTS` (env, default 10) — no external job queue in
|
||||
v1. When all slots are busy the request gets `503` with `Retry-After` immediately, it is not queued.
|
||||
- **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 (cookies, local storage, user agent) on disk.
|
||||
- **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**: single Docker container; TLS is terminated by Caddy on the host — see
|
||||
- **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** is generated anew for every session by design.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.py # FastAPI app: /v1/solve, /health, slots, sessions, response
|
||||
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/
|
||||
schemas.py # SolveRequest / ActionRequest / SolveResponse + validation
|
||||
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
|
||||
@@ -64,12 +83,18 @@ src/
|
||||
known_providers/ # cloudflare.py, recaptcha.py
|
||||
humanize.py # human-like mouse moves and typing
|
||||
captcha/ # 2captcha solvers: Turnstile, reCAPTCHA, hCaptcha
|
||||
client/ # Python client package robotex (pip install, make client-build)
|
||||
docs/ # API, deployment, anti-bot notes
|
||||
tests/
|
||||
unit/ # pytest without a browser: make test
|
||||
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
|
||||
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
|
||||
@@ -77,6 +102,8 @@ Makefile
|
||||
|
||||
## API
|
||||
|
||||
Synchronous:
|
||||
|
||||
```
|
||||
POST /v1/solve
|
||||
X-API-Key: <key>
|
||||
@@ -106,14 +133,33 @@ Response:
|
||||
```
|
||||
|
||||
On failure `status` is `"err"` with a machine-readable `code` (`no_slots`, `request_failed`, `challenge_failed`,
|
||||
`action_failed`) and a human-readable `error`. Full reference — fields, action types, errors, sessions:
|
||||
[docs/API.md](docs/API.md).
|
||||
`action_failed`) and a human-readable `error`.
|
||||
|
||||
Asynchronous, keeping the browser window open between tasks:
|
||||
|
||||
```
|
||||
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
|
||||
- Second engine (Firefox, e.g. Camoufox) for targets where the Chrome fingerprint doesn't fit
|
||||
@@ -121,28 +167,45 @@ Deliberately out of scope for v1:
|
||||
## 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>
|
||||
make dev # api + worker on the host, Chrome windows visible on your desktop; API on :8010
|
||||
make dev-key name=local # API key for it (stored in the same postgres volume as make up)
|
||||
```
|
||||
|
||||
Chrome runs headful, so a display is required (locally — your desktop, in Docker — Xvfb).
|
||||
`make dev` stops the containerized api and worker (otherwise the Docker worker also takes tasks from the common
|
||||
queue and runs them invisibly), starts rabbitmq, redis and postgres with ports published on 127.0.0.1
|
||||
(`docker-compose.dev.yml`: 5673, 6380, 5433), then runs uvicorn with `--reload` and the dramatiq worker in the
|
||||
foreground. Ctrl+C stops both; `make down` stops the infrastructure. Ports, slots: `DEV_API_PORT`,
|
||||
`DEV_RABBITMQ_PORT`, `DEV_REDIS_PORT`, `DEV_POSTGRES_PORT`, `DEV_SLOTS` (default 2). 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.
|
||||
worker's `extra` named volume, so sessions survive container restarts.
|
||||
|
||||
Releases:
|
||||
|
||||
```bash
|
||||
make build ver=v0.0.2 # docker image robotex:v0.0.2
|
||||
make release ver=v0.0.2 # image + deploy bundle in build/v0.0.2/ (compose, .env, image .tar.gz)
|
||||
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 # push images to docker.sokolab.xyz + deploy bundle in build/v0.1.0/ (compose, .env)
|
||||
make release ver=v0.1.0 out=file # same, but images go to build/v0.1.0/robotex-v0.1.0.tar.gz instead of the registry
|
||||
```
|
||||
|
||||
Details (Xvfb, tini, `/dev/shm`, env settings, load measurements) — [docs/Deployment.md](docs/Deployment.md).
|
||||
@@ -0,0 +1,104 @@
|
||||
# robotex — Python-клиент Robotex API
|
||||
|
||||
Обвязка над HTTP API Robotex (docs/API.md): задачи v2 через очередь, синхронный `/v1/solve`, сессии с удержанием окна
|
||||
браузера. Синхронный `Robotex` и асинхронный `AsyncRobotex` с одинаковым интерфейсом. Зависимость одна — `httpx`.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.1.0#subdirectory=client"
|
||||
```
|
||||
|
||||
или из wheel (`make client-build` в репозитории кладёт его в `client/dist/`):
|
||||
|
||||
```bash
|
||||
pip install robotex-0.1.0-py3-none-any.whl
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```python
|
||||
from robotex import Robotex, Action
|
||||
|
||||
with Robotex("https://robotex.example.com", api_key="rbx_...") as rx:
|
||||
result = rx.run(
|
||||
"https://example.com",
|
||||
[Action.wait_for("h1"), Action.inner_text("h1")],
|
||||
html=True,
|
||||
).raise_for_status()
|
||||
print(result.results[0], result.user_agent, result.cookie("cf_clearance"))
|
||||
```
|
||||
|
||||
`base_url` и `api_key` можно не передавать — тогда берутся из `ROBOTEX_URL` и `ROBOTEX_API_KEY`.
|
||||
|
||||
Остальные параметры запроса передаются именованными аргументами как есть: `proxy`, `cookies`, `html`, `screenshot`,
|
||||
`timeout`, `captcha`, `captcha_type`, `disable_resources`, `blocked_domains`, `retries`, `wait`, `google_search` и т. д.
|
||||
|
||||
## Методы
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `run(url, actions, **opts)` | задача v2: поставить в очередь и дождаться результата (`wait=` секунд, по умолчанию 600) |
|
||||
| `submit(url, actions, **opts)` | только поставить; вернёт `TaskRef` с `.status()` и `.wait()` |
|
||||
| `solve(url, actions, **opts)` | синхронный `/v1/solve`; `NoSlotsError`, если свободных браузеров нет |
|
||||
| `session(session_id=None, idle_timeout=5)` | сессия: один профиль и окно, которое держится `idle_timeout` минут |
|
||||
| `close_session(session_id)` | закрыть окно сессии и освободить слот |
|
||||
| `health()` | слоты и воркеры |
|
||||
|
||||
Вызовы без сессии каждый раз получают новый профиль браузера.
|
||||
|
||||
## Сессии и удержание окна
|
||||
|
||||
```python
|
||||
with rx.session(idle_timeout=10) as s:
|
||||
s.run("https://site/login", [
|
||||
Action.typing("#login", "me"),
|
||||
Action.typing("#password", "secret"),
|
||||
Action.submit("button[type=submit]"),
|
||||
]).raise_for_status()
|
||||
# без url — на текущей странице того же окна, без повторного запуска Chrome и антибота
|
||||
balance = s.run(actions=[Action.inner_text(".balance")]).results[0]
|
||||
saved = s.id
|
||||
# на выходе из with окно закрывается (слот освобождается через секунду-две), профиль остаётся
|
||||
|
||||
again = rx.session(saved).run("https://site/account") # продолжить сессию позже
|
||||
```
|
||||
|
||||
## Асинхронно
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from robotex import AsyncRobotex, Action
|
||||
|
||||
async def main():
|
||||
async with AsyncRobotex() as rx:
|
||||
results = await asyncio.gather(*(rx.run(url, [Action.inner_text("h1")]) for url in urls))
|
||||
async with rx.session(idle_timeout=5) as s:
|
||||
await s.run("https://example.com")
|
||||
```
|
||||
|
||||
## Результат и ошибки
|
||||
|
||||
`Result`: `status` (`ok`/`err`), `code`, `error`, `user_agent`, `cookies`, `actions` (`ActionResult`), `results`
|
||||
(тексты действий по порядку), `html`, `screenshot` (base64, `screenshot_bytes()`), `duration_ms`, `session_id`.
|
||||
|
||||
Ошибка выполнения (`challenge_failed`, `action_failed`, `request_failed`) — это результат со `status="err"`, исключения
|
||||
нет; `raise_for_status()` превращает её в `ResultError` (в `e.result` всё собранное: cookies, html, скриншот).
|
||||
|
||||
Исключения (все от `RobotexError`):
|
||||
|
||||
| Исключение | Когда |
|
||||
|---|---|
|
||||
| `AuthError` | 401 — ключ не передан, неверен или отозван |
|
||||
| `InvalidRequest` | 422 — неверное действие, нет `url` без открытого окна и т. п. |
|
||||
| `NoSlotsError` | 503 у `solve()`; `retry_after` — через сколько секунд повторить |
|
||||
| `NotFound` | 404 — задача истекла или из другой сессии |
|
||||
| `TaskFailed` | задача `failed`/`cancelled` (например, упал воркер) |
|
||||
| `TaskTimeout` | `wait` истёк; задача продолжает выполняться, `TaskRef.wait()` можно повторить |
|
||||
| `ResultError` | `raise_for_status()` на результате со `status="err"` |
|
||||
|
||||
## Действия
|
||||
|
||||
`Action.fill`, `typing`, `click`, `submit`, `mouse_move`, `delay(seconds)`, `wait_for(selector, timeout=None)`,
|
||||
`inner_html`, `inner_text`, `captcha(selector, kind)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
|
||||
API: `{"type": "click", "selector": "#a"}`.
|
||||
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "robotex"
|
||||
version = "0.1.0"
|
||||
description = "Python-клиент Robotex API: браузер с обходом антиботов, задачи v2 и окна сессий"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["httpx>=0.27,<1"]
|
||||
|
||||
[project.urls]
|
||||
Source = "https://git.sokolab.xyz/s0k0l/robotex"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
robotex = ["py.typed"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Клиент Robotex API.
|
||||
|
||||
from robotex import Robotex, Action
|
||||
|
||||
with Robotex("https://robotex.example.com", api_key="rbx_...") as rx:
|
||||
result = rx.run("https://example.com", [Action.inner_text("h1")]).raise_for_status()
|
||||
print(result.results[0])
|
||||
"""
|
||||
from robotex.actions import Action
|
||||
from robotex.aio import AsyncRobotex, AsyncSession, AsyncTaskRef
|
||||
from robotex.client import Robotex, Session, TaskRef
|
||||
from robotex.errors import (
|
||||
AuthError,
|
||||
InvalidRequest,
|
||||
NoSlotsError,
|
||||
NotFound,
|
||||
ResultError,
|
||||
RobotexError,
|
||||
TaskFailed,
|
||||
TaskTimeout,
|
||||
)
|
||||
from robotex.models import ActionResult, Result, Task
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"Action", "ActionResult", "AsyncRobotex", "AsyncSession", "AsyncTaskRef", "AuthError", "InvalidRequest",
|
||||
"NoSlotsError", "NotFound", "Result", "ResultError", "Robotex", "RobotexError", "Session", "Task", "TaskFailed",
|
||||
"TaskRef", "TaskTimeout",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Общее для синхронного и асинхронного клиента: тело запроса, cookie сессии, разбор ответов."""
|
||||
import os
|
||||
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from robotex.actions import Action
|
||||
from robotex.errors import AuthError, InvalidRequest, NoSlotsError, NotFound, RobotexError, TaskFailed
|
||||
from robotex.models import Result, Task
|
||||
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
DEFAULT_POLL_INTERVAL = 1.0
|
||||
DEFAULT_WAIT = 600.0
|
||||
SESSION_COOKIE = "session_id"
|
||||
|
||||
ActionLike = Action | dict[str, Any]
|
||||
|
||||
|
||||
def settings(base_url: str | None, api_key: str | None) -> tuple[str, str]:
|
||||
base_url = base_url or os.getenv("ROBOTEX_URL")
|
||||
api_key = api_key or os.getenv("ROBOTEX_API_KEY")
|
||||
if not base_url:
|
||||
raise ValueError("base_url is required (or set ROBOTEX_URL)")
|
||||
if not api_key:
|
||||
raise ValueError("api_key is required (or set ROBOTEX_API_KEY)")
|
||||
return base_url.rstrip("/"), api_key
|
||||
|
||||
|
||||
def client_kwargs(base_url: str, api_key: str, timeout: float, transport) -> dict[str, Any]:
|
||||
# cookie session_id клиент передаёт сам: общая банка cookies смешала бы сессии разных вызовов
|
||||
return {
|
||||
"base_url": base_url,
|
||||
"headers": {"X-API-Key": api_key, "User-Agent": "robotex-python"},
|
||||
"timeout": timeout,
|
||||
"transport": transport,
|
||||
"cookies": CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
|
||||
}
|
||||
|
||||
|
||||
def session_headers(session_id: str | None) -> dict[str, str]:
|
||||
return {"Cookie": f"{SESSION_COOKIE}={session_id}"} if session_id else {}
|
||||
|
||||
|
||||
def body(url: str | None, actions: Iterable[ActionLike], options: dict[str, Any]) -> dict[str, Any]:
|
||||
data = {k: v for k, v in options.items() if v is not None}
|
||||
data["actions"] = [a.to_dict() if isinstance(a, Action) else dict(a) for a in actions]
|
||||
if url is not None:
|
||||
data["url"] = url
|
||||
return data
|
||||
|
||||
|
||||
def response_session(response: httpx.Response, fallback: str | None) -> str | None:
|
||||
for header in response.headers.get_list("set-cookie"):
|
||||
morsel = SimpleCookie(header).get(SESSION_COOKIE)
|
||||
if morsel and morsel.value:
|
||||
return morsel.value
|
||||
return fallback
|
||||
|
||||
|
||||
def _detail(response: httpx.Response) -> str:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(detail, list): # ошибки проверки FastAPI
|
||||
return "; ".join(f"{'.'.join(map(str, e.get('loc', [])[1:]))}: {e.get('msg')}" for e in detail)
|
||||
return str(detail)
|
||||
|
||||
|
||||
def check(response: httpx.Response) -> httpx.Response:
|
||||
code = response.status_code
|
||||
if code < 400:
|
||||
return response
|
||||
if code == 401:
|
||||
raise AuthError(_detail(response), code)
|
||||
if code == 404:
|
||||
raise NotFound(_detail(response), code)
|
||||
if code == 422:
|
||||
raise InvalidRequest(_detail(response), code)
|
||||
if code == 503:
|
||||
retry_after = response.headers.get("retry-after")
|
||||
raise NoSlotsError("no free browser slots", float(retry_after) if retry_after else None)
|
||||
raise RobotexError(f"HTTP {code}: {response.text[:500]}", code)
|
||||
|
||||
|
||||
def finished_result(task: Task, session_id: str | None) -> Result | None:
|
||||
"""Result готовой задачи, None — ещё выполняется; TaskFailed — не выполнена."""
|
||||
if task.status == "done":
|
||||
return task.result or Result("err", "request_failed", "empty result", session_id=session_id)
|
||||
if task.status in ("failed", "cancelled"):
|
||||
raise TaskFailed(f"task {task.task_id} {task.status}: {task.error}", task.task_id, task.status)
|
||||
return None
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Конструкторы действий — те же типы, что в docs/API.md. Вместо Action можно передавать dict."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
type: str
|
||||
selector: str | None = None
|
||||
value: str | None = None
|
||||
timeout: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {k: v for k, v in vars(self).items() if v is not None}
|
||||
|
||||
@classmethod
|
||||
def fill(cls, selector: str, value: str) -> "Action":
|
||||
"""Вставить значение в поле мгновенно."""
|
||||
return cls("fill", selector, value)
|
||||
|
||||
@classmethod
|
||||
def typing(cls, selector: str, value: str) -> "Action":
|
||||
"""Набрать значение по-человечески: движение мыши, клик, нажатия с паузами."""
|
||||
return cls("typing", selector, value)
|
||||
|
||||
@classmethod
|
||||
def click(cls, selector: str) -> "Action":
|
||||
return cls("click", selector)
|
||||
|
||||
@classmethod
|
||||
def submit(cls, selector: str) -> "Action":
|
||||
"""Клик и ожидание навигации."""
|
||||
return cls("submit", selector)
|
||||
|
||||
@classmethod
|
||||
def mouse_move(cls, selector: str) -> "Action":
|
||||
return cls("mouse_move", selector)
|
||||
|
||||
@classmethod
|
||||
def delay(cls, seconds: int) -> "Action":
|
||||
"""Пауза 0–600 секунд."""
|
||||
return cls("delay", value=str(int(seconds)))
|
||||
|
||||
@classmethod
|
||||
def wait_for(cls, selector: str, timeout: int | None = None) -> "Action":
|
||||
"""Дождаться элемента; timeout 1–7200 секунд, по умолчанию 30."""
|
||||
return cls("wait_for", selector, timeout=timeout)
|
||||
|
||||
@classmethod
|
||||
def inner_html(cls, selector: str) -> "Action":
|
||||
return cls("inner_html", selector)
|
||||
|
||||
@classmethod
|
||||
def inner_text(cls, selector: str) -> "Action":
|
||||
return cls("inner_text", selector)
|
||||
|
||||
@classmethod
|
||||
def captcha(cls, selector: str, kind: str) -> "Action":
|
||||
"""Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ)."""
|
||||
if kind not in CAPTCHA_TYPES:
|
||||
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)}")
|
||||
return cls("captcha", selector, kind)
|
||||
|
||||
@classmethod
|
||||
def pass_challenge(cls) -> "Action":
|
||||
"""Пройти антибот на текущей странице (например, после submit)."""
|
||||
return cls("pass_challenge")
|
||||
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from robotex import _core
|
||||
from robotex._core import ActionLike
|
||||
from robotex.errors import TaskTimeout
|
||||
from robotex.models import Result, Task
|
||||
|
||||
|
||||
class AsyncRobotex:
|
||||
"""Асинхронный клиент, тот же интерфейс, что у Robotex. base_url и api_key по умолчанию берутся из ROBOTEX_URL и ROBOTEX_API_KEY.
|
||||
|
||||
Каждый вызов без сессии получает новый профиль браузера; чтобы сохранять cookies и держать окно
|
||||
между задачами, используйте session().
|
||||
Параметры запроса (proxy, cookies, html, screenshot, captcha, wait, …) передаются как именованные
|
||||
аргументы, см. docs/API.md.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
timeout: float = _core.DEFAULT_TIMEOUT,
|
||||
poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
):
|
||||
base_url, api_key = _core.settings(base_url, api_key)
|
||||
self.poll_interval = poll_interval
|
||||
self._http = httpx.AsyncClient(**_core.client_kwargs(base_url, api_key, timeout, transport))
|
||||
|
||||
async def __aenter__(self) -> "AsyncRobotex":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
"""Состояние сервиса: browser_slots, free_slots, воркеры. Ключ не нужен."""
|
||||
return _core.check(await self._http.get("/health")).json()
|
||||
|
||||
async def solve(
|
||||
self, url: str, actions: Iterable[ActionLike] = (), *, session_id: str | None = None, **options: Any,
|
||||
) -> Result:
|
||||
"""Синхронный /v1/solve: ответ, когда задача выполнена. NoSlotsError, если браузеров нет."""
|
||||
response = _core.check(await self._http.post(
|
||||
"/v1/solve", json=_core.body(url, actions, options), headers=_core.session_headers(session_id),
|
||||
timeout=httpx.Timeout(self._http.timeout.connect, read=None),
|
||||
))
|
||||
return Result.from_dict(response.json(), _core.response_session(response, session_id))
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
url: str | None = None,
|
||||
actions: Iterable[ActionLike] = (),
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
idle_timeout: int = 0,
|
||||
**options: Any,
|
||||
) -> "AsyncTaskRef":
|
||||
"""Поставить задачу v2 в очередь. idle_timeout — минуты (0–120), сколько держать окно после задачи.
|
||||
Без url задача выполняется на текущей странице открытого окна сессии."""
|
||||
data = _core.body(url, actions, options)
|
||||
data["browser_idle_timeout"] = idle_timeout
|
||||
response = _core.check(await self._http.post("/v2/tasks", json=data, headers=_core.session_headers(session_id)))
|
||||
return AsyncTaskRef(self, response.json()["task_id"], _core.response_session(response, session_id))
|
||||
|
||||
async def task(self, task_id: str, session_id: str) -> Task:
|
||||
"""Состояние задачи. Видна только из своей сессии."""
|
||||
response = _core.check(await self._http.get(f"/v2/tasks/{task_id}", headers=_core.session_headers(session_id)))
|
||||
return Task.from_dict(response.json(), session_id)
|
||||
|
||||
async def wait(
|
||||
self, task_id: str, session_id: str, timeout: float | None = _core.DEFAULT_WAIT,
|
||||
poll_interval: float | None = None,
|
||||
) -> Result:
|
||||
"""Дождаться результата задачи. TaskTimeout — не успела (выполняется дальше), TaskFailed — не выполнена."""
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
interval = poll_interval or self.poll_interval
|
||||
while True:
|
||||
result = _core.finished_result(await self.task(task_id, session_id), session_id)
|
||||
if result is not None:
|
||||
return result
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise TaskTimeout(f"task {task_id} is not finished after {timeout}s", task_id)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
url: str | None = None,
|
||||
actions: Iterable[ActionLike] = (),
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
idle_timeout: int = 0,
|
||||
wait: float | None = _core.DEFAULT_WAIT,
|
||||
**options: Any,
|
||||
) -> Result:
|
||||
"""submit() и wait(): задача через очередь, без 503 при занятых браузерах."""
|
||||
ref = await self.submit(url, actions, session_id=session_id, idle_timeout=idle_timeout, **options)
|
||||
return await ref.wait(wait)
|
||||
|
||||
async def close_session(self, session_id: str) -> bool:
|
||||
"""Закрыть окно сессии и освободить слот. False — окна не было."""
|
||||
response = await self._http.delete("/v2/sessions/current", headers=_core.session_headers(session_id))
|
||||
return _core.check(response).json()["closed"]
|
||||
|
||||
def session(self, session_id: str | None = None, idle_timeout: int = 5) -> "AsyncSession":
|
||||
"""Сессия: один профиль браузера (cookies, localStorage, отпечаток) и окно, которое держится
|
||||
idle_timeout минут между задачами. session_id — продолжить сохранённую сессию."""
|
||||
return AsyncSession(self, session_id, idle_timeout)
|
||||
|
||||
|
||||
class AsyncTaskRef:
|
||||
"""Поставленная задача v2."""
|
||||
|
||||
def __init__(self, client: AsyncRobotex, task_id: str, session_id: str):
|
||||
self.client, self.task_id, self.session_id = client, task_id, session_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AsyncTaskRef({self.task_id!r}, session_id={self.session_id!r})"
|
||||
|
||||
async def status(self) -> Task:
|
||||
return await self.client.task(self.task_id, self.session_id)
|
||||
|
||||
async def wait(self, timeout: float | None = _core.DEFAULT_WAIT, poll_interval: float | None = None) -> Result:
|
||||
return await self.client.wait(self.task_id, self.session_id, timeout, poll_interval)
|
||||
|
||||
|
||||
class AsyncSession:
|
||||
"""Задачи одной сессии. В with-блоке окно закрывается на выходе.
|
||||
|
||||
async with rx.session(idle_timeout=10) as s:
|
||||
await s.run("https://site/login", [Action.typing("#user", "me"), Action.submit("button")])
|
||||
text = (await s.run(actions=[Action.inner_text(".balance")])).results[0] # та же страница
|
||||
"""
|
||||
|
||||
def __init__(self, client: AsyncRobotex, session_id: str | None = None, idle_timeout: int = 5):
|
||||
self.client, self.id, self.idle_timeout = client, session_id, idle_timeout
|
||||
|
||||
async def __aenter__(self) -> "AsyncSession":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
await self.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AsyncSession({self.id!r}, idle_timeout={self.idle_timeout})"
|
||||
|
||||
async def submit(self, url: str | None = None, actions: Iterable[ActionLike] = (), **options: Any) -> AsyncTaskRef:
|
||||
options.setdefault("idle_timeout", self.idle_timeout)
|
||||
ref = await self.client.submit(url, actions, session_id=self.id, **options)
|
||||
self.id = ref.session_id
|
||||
return ref
|
||||
|
||||
async def run(
|
||||
self, url: str | None = None, actions: Iterable[ActionLike] = (), *,
|
||||
wait: float | None = _core.DEFAULT_WAIT, **options: Any,
|
||||
) -> Result:
|
||||
return await (await self.submit(url, actions, **options)).wait(wait)
|
||||
|
||||
async def solve(self, url: str, actions: Iterable[ActionLike] = (), **options: Any) -> Result:
|
||||
result = await self.client.solve(url, actions, session_id=self.id, **options)
|
||||
self.id = result.session_id
|
||||
return result
|
||||
|
||||
async def close(self) -> bool:
|
||||
"""Закрыть окно браузера; профиль остаётся, сессию можно продолжить."""
|
||||
return await self.client.close_session(self.id) if self.id else False
|
||||
@@ -0,0 +1,173 @@
|
||||
import time
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from robotex import _core
|
||||
from robotex._core import ActionLike
|
||||
from robotex.errors import TaskTimeout
|
||||
from robotex.models import Result, Task
|
||||
|
||||
|
||||
class Robotex:
|
||||
"""Синхронный клиент. base_url и api_key по умолчанию берутся из ROBOTEX_URL и ROBOTEX_API_KEY.
|
||||
|
||||
Каждый вызов без сессии получает новый профиль браузера; чтобы сохранять cookies и держать окно
|
||||
между задачами, используйте session().
|
||||
Параметры запроса (proxy, cookies, html, screenshot, captcha, wait, …) передаются как именованные
|
||||
аргументы, см. docs/API.md.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
timeout: float = _core.DEFAULT_TIMEOUT,
|
||||
poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
):
|
||||
base_url, api_key = _core.settings(base_url, api_key)
|
||||
self.poll_interval = poll_interval
|
||||
self._http = httpx.Client(**_core.client_kwargs(base_url, api_key, timeout, transport))
|
||||
|
||||
def __enter__(self) -> "Robotex":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""Состояние сервиса: browser_slots, free_slots, воркеры. Ключ не нужен."""
|
||||
return _core.check(self._http.get("/health")).json()
|
||||
|
||||
def solve(
|
||||
self, url: str, actions: Iterable[ActionLike] = (), *, session_id: str | None = None, **options: Any,
|
||||
) -> Result:
|
||||
"""Синхронный /v1/solve: ответ, когда задача выполнена. NoSlotsError, если браузеров нет."""
|
||||
response = _core.check(self._http.post(
|
||||
"/v1/solve", json=_core.body(url, actions, options), headers=_core.session_headers(session_id),
|
||||
timeout=httpx.Timeout(self._http.timeout.connect, read=None),
|
||||
))
|
||||
return Result.from_dict(response.json(), _core.response_session(response, session_id))
|
||||
|
||||
def submit(
|
||||
self,
|
||||
url: str | None = None,
|
||||
actions: Iterable[ActionLike] = (),
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
idle_timeout: int = 0,
|
||||
**options: Any,
|
||||
) -> "TaskRef":
|
||||
"""Поставить задачу v2 в очередь. idle_timeout — минуты (0–120), сколько держать окно после задачи.
|
||||
Без url задача выполняется на текущей странице открытого окна сессии."""
|
||||
data = _core.body(url, actions, options)
|
||||
data["browser_idle_timeout"] = idle_timeout
|
||||
response = _core.check(self._http.post("/v2/tasks", json=data, headers=_core.session_headers(session_id)))
|
||||
return TaskRef(self, response.json()["task_id"], _core.response_session(response, session_id))
|
||||
|
||||
def task(self, task_id: str, session_id: str) -> Task:
|
||||
"""Состояние задачи. Видна только из своей сессии."""
|
||||
response = _core.check(self._http.get(f"/v2/tasks/{task_id}", headers=_core.session_headers(session_id)))
|
||||
return Task.from_dict(response.json(), session_id)
|
||||
|
||||
def wait(
|
||||
self, task_id: str, session_id: str, timeout: float | None = _core.DEFAULT_WAIT,
|
||||
poll_interval: float | None = None,
|
||||
) -> Result:
|
||||
"""Дождаться результата задачи. TaskTimeout — не успела (выполняется дальше), TaskFailed — не выполнена."""
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
interval = poll_interval or self.poll_interval
|
||||
while True:
|
||||
result = _core.finished_result(self.task(task_id, session_id), session_id)
|
||||
if result is not None:
|
||||
return result
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise TaskTimeout(f"task {task_id} is not finished after {timeout}s", task_id)
|
||||
time.sleep(interval)
|
||||
|
||||
def run(
|
||||
self,
|
||||
url: str | None = None,
|
||||
actions: Iterable[ActionLike] = (),
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
idle_timeout: int = 0,
|
||||
wait: float | None = _core.DEFAULT_WAIT,
|
||||
**options: Any,
|
||||
) -> Result:
|
||||
"""submit() и wait(): задача через очередь, без 503 при занятых браузерах."""
|
||||
ref = self.submit(url, actions, session_id=session_id, idle_timeout=idle_timeout, **options)
|
||||
return ref.wait(wait)
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
"""Закрыть окно сессии и освободить слот. False — окна не было."""
|
||||
response = self._http.delete("/v2/sessions/current", headers=_core.session_headers(session_id))
|
||||
return _core.check(response).json()["closed"]
|
||||
|
||||
def session(self, session_id: str | None = None, idle_timeout: int = 5) -> "Session":
|
||||
"""Сессия: один профиль браузера (cookies, localStorage, отпечаток) и окно, которое держится
|
||||
idle_timeout минут между задачами. session_id — продолжить сохранённую сессию."""
|
||||
return Session(self, session_id, idle_timeout)
|
||||
|
||||
|
||||
class TaskRef:
|
||||
"""Поставленная задача v2."""
|
||||
|
||||
def __init__(self, client: Robotex, task_id: str, session_id: str):
|
||||
self.client, self.task_id, self.session_id = client, task_id, session_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TaskRef({self.task_id!r}, session_id={self.session_id!r})"
|
||||
|
||||
def status(self) -> Task:
|
||||
return self.client.task(self.task_id, self.session_id)
|
||||
|
||||
def wait(self, timeout: float | None = _core.DEFAULT_WAIT, poll_interval: float | None = None) -> Result:
|
||||
return self.client.wait(self.task_id, self.session_id, timeout, poll_interval)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Задачи одной сессии. В with-блоке окно закрывается на выходе.
|
||||
|
||||
with rx.session(idle_timeout=10) as s:
|
||||
s.run("https://site/login", [Action.typing("#user", "me"), Action.submit("button")])
|
||||
text = s.run(actions=[Action.inner_text(".balance")]).results[0] # та же страница
|
||||
"""
|
||||
|
||||
def __init__(self, client: Robotex, session_id: str | None = None, idle_timeout: int = 5):
|
||||
self.client, self.id, self.idle_timeout = client, session_id, idle_timeout
|
||||
|
||||
def __enter__(self) -> "Session":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Session({self.id!r}, idle_timeout={self.idle_timeout})"
|
||||
|
||||
def submit(self, url: str | None = None, actions: Iterable[ActionLike] = (), **options: Any) -> TaskRef:
|
||||
options.setdefault("idle_timeout", self.idle_timeout)
|
||||
ref = self.client.submit(url, actions, session_id=self.id, **options)
|
||||
self.id = ref.session_id
|
||||
return ref
|
||||
|
||||
def run(
|
||||
self, url: str | None = None, actions: Iterable[ActionLike] = (), *,
|
||||
wait: float | None = _core.DEFAULT_WAIT, **options: Any,
|
||||
) -> Result:
|
||||
return self.submit(url, actions, **options).wait(wait)
|
||||
|
||||
def solve(self, url: str, actions: Iterable[ActionLike] = (), **options: Any) -> Result:
|
||||
result = self.client.solve(url, actions, session_id=self.id, **options)
|
||||
self.id = result.session_id
|
||||
return result
|
||||
|
||||
def close(self) -> bool:
|
||||
"""Закрыть окно браузера; профиль остаётся, сессию можно продолжить."""
|
||||
return self.client.close_session(self.id) if self.id else False
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from robotex.models import Result
|
||||
|
||||
|
||||
class RobotexError(Exception):
|
||||
"""Базовая ошибка клиента. status_code — HTTP-код ответа, если ошибка пришла от API."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class AuthError(RobotexError):
|
||||
"""401: ключ X-API-Key не передан, неверен или отозван."""
|
||||
|
||||
|
||||
class InvalidRequest(RobotexError):
|
||||
"""422: запрос не прошёл проверку (неизвестное действие, нет url без открытого окна и т. п.)."""
|
||||
|
||||
|
||||
class NotFound(RobotexError):
|
||||
"""404: задачи нет, она истекла или принадлежит другой сессии."""
|
||||
|
||||
|
||||
class NoSlotsError(RobotexError):
|
||||
"""503 у /v1/solve: свободных браузеров нет. retry_after — через сколько секунд повторить."""
|
||||
|
||||
def __init__(self, message: str, retry_after: float | None = None):
|
||||
super().__init__(message, 503)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class TaskFailed(RobotexError):
|
||||
"""Задачу не выполнили (failed или cancelled), например упал воркер."""
|
||||
|
||||
def __init__(self, message: str, task_id: str, status: str):
|
||||
super().__init__(message)
|
||||
self.task_id = task_id
|
||||
self.status = status
|
||||
|
||||
|
||||
class TaskTimeout(RobotexError, TimeoutError):
|
||||
"""Задача не завершилась за отведённое ожидание. Она продолжает выполняться — результат можно забрать позже."""
|
||||
|
||||
def __init__(self, message: str, task_id: str):
|
||||
super().__init__(message)
|
||||
self.task_id = task_id
|
||||
|
||||
|
||||
class ResultError(RobotexError):
|
||||
"""Задача выполнена, но со status err: code — challenge_failed, action_failed, request_failed.
|
||||
В result — всё, что успели собрать (cookies, html, screenshot, действия до упавшего)."""
|
||||
|
||||
def __init__(self, result: "Result"):
|
||||
super().__init__(f"{result.code}: {result.error}")
|
||||
self.code = result.code
|
||||
self.result = result
|
||||
@@ -0,0 +1,89 @@
|
||||
import base64
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from robotex.errors import ResultError
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
type: str
|
||||
ok: bool
|
||||
result: str | None = None
|
||||
duration_ms: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ActionResult":
|
||||
return cls(data["type"], data["ok"], data.get("result"), data.get("duration_ms", 0))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""Результат задачи (как ответ /v1/solve). status — ok или err, при err причина в code и error."""
|
||||
status: str
|
||||
code: str | None = None
|
||||
error: str | None = None
|
||||
user_agent: str | None = None
|
||||
cookies: list[dict[str, Any]] = field(default_factory=list)
|
||||
actions: list[ActionResult] = field(default_factory=list)
|
||||
html: str | None = None
|
||||
screenshot: str | None = None
|
||||
duration_ms: int | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == "ok"
|
||||
|
||||
@property
|
||||
def results(self) -> list[str | None]:
|
||||
"""Результаты действий по порядку (текст для inner_text/inner_html, None для остальных)."""
|
||||
return [a.result for a in self.actions]
|
||||
|
||||
def screenshot_bytes(self) -> bytes | None:
|
||||
return base64.b64decode(self.screenshot) if self.screenshot else None
|
||||
|
||||
def cookie(self, name: str) -> str | None:
|
||||
return next((c.get("value") for c in self.cookies if c.get("name") == name), None)
|
||||
|
||||
def raise_for_status(self) -> "Result":
|
||||
"""ResultError, если status err; иначе сам результат — удобно в цепочке."""
|
||||
if not self.ok:
|
||||
raise ResultError(self)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], session_id: str | None = None) -> "Result":
|
||||
return cls(
|
||||
status=data["status"],
|
||||
code=data.get("code"),
|
||||
error=data.get("error"),
|
||||
user_agent=data.get("user_agent"),
|
||||
cookies=data.get("cookies") or [],
|
||||
actions=[ActionResult.from_dict(a) for a in data.get("actions") or []],
|
||||
html=data.get("html"),
|
||||
screenshot=data.get("screenshot"),
|
||||
duration_ms=data.get("duration_ms"),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Состояние задачи v2: queued → running → done | failed | cancelled."""
|
||||
task_id: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
result: Result | None = None
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self.status in ("done", "failed", "cancelled")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], session_id: str | None = None) -> "Task":
|
||||
result = data.get("result")
|
||||
return cls(
|
||||
data["task_id"], data["status"], data.get("error"),
|
||||
Result.from_dict(result, session_id) if result else None,
|
||||
)
|
||||
Whitespace-only changes.
@@ -0,0 +1,15 @@
|
||||
# Локальная разработка (make dev, #90): поверх docker-compose.yml открывает порты rabbitmq, redis и postgres
|
||||
# на 127.0.0.1, чтобы api и worker запускались на хосте — с видимым окном Chrome на рабочем столе.
|
||||
# Порты нестандартные: 5672/5432 на хосте часто заняты другими проектами.
|
||||
services:
|
||||
rabbitmq:
|
||||
ports:
|
||||
- "127.0.0.1:${DEV_RABBITMQ_PORT:-5673}:5672"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "127.0.0.1:${DEV_REDIS_PORT:-6380}:6379"
|
||||
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:${DEV_POSTGRES_PORT:-5433}:5432"
|
||||
+73
-4
@@ -1,22 +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: robotex:v0.0.1
|
||||
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: ${API_CPUS:-4}
|
||||
mem_limit: ${API_MEM_LIMIT:-8g}
|
||||
memswap_limit: ${API_MEM_LIMIT:-8g}
|
||||
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:
|
||||
+81
-6
@@ -1,25 +1,97 @@
|
||||
# Локальный запуск и тесты: 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:latest
|
||||
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: ${API_CPUS:-4}
|
||||
mem_limit: ${API_MEM_LIMIT:-8g}
|
||||
memswap_limit: ${API_MEM_LIMIT:-8g}
|
||||
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
|
||||
# из контейнера api доступен как http://proxy:8080
|
||||
# из контейнера worker доступен как http://proxy:8080
|
||||
proxy:
|
||||
image: gogost/gost:3.1.0
|
||||
profiles: ["loadtest"]
|
||||
@@ -28,3 +100,6 @@ services:
|
||||
|
||||
volumes:
|
||||
extra:
|
||||
rabbitmq:
|
||||
redis:
|
||||
postgres:
|
||||
+108
-13
@@ -1,12 +1,26 @@
|
||||
# API
|
||||
|
||||
Основной эндпоинт: `POST /v1/solve`. Открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит
|
||||
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и,
|
||||
по запросу, HTML и скриншот.
|
||||
Как встраивать API в свой клиент (UA, прокси, сессии, повторы) — [Usage.md](Usage.md).
|
||||
|
||||
`GET /health` (и `GET /`) — `{"status": "ok", "browser_slots": N, "free_slots": M}`.
|
||||
Задача открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит антибот
|
||||
(Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и, по запросу,
|
||||
HTML и скриншот. Задачи выполняет сервис worker из очереди; API работает в двух режимах:
|
||||
|
||||
> `X-API-Key` пока не проверяется ([#4](https://redmine.sokolab.xyz/issues/4)).
|
||||
* `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`, а
|
||||
чужое открытое окно не используется.
|
||||
|
||||
## Запрос
|
||||
|
||||
@@ -40,8 +54,7 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
| `html` | bool | `false` | вернуть HTML страницы после выполнения действий |
|
||||
| `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота |
|
||||
| `timeout` | int, с | `60` | таймаут навигации |
|
||||
| `user_agent` | string | `null` | свой User-Agent; запоминается в сессии (см. «Сессии»). Без него — UA сессии или UA установленного Chrome |
|
||||
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` |
|
||||
| `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` | попыток навигации |
|
||||
@@ -62,7 +75,9 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
| `wait_for` | `selector`, `timeout` (1–7200 с, по умолчанию 30) | ждёт появления элемента; при таймауте — ошибка действия |
|
||||
| `click` | `selector` | клик по элементу |
|
||||
| `submit` | `selector` | клик и ожидание навигации |
|
||||
| `fill` | `selector`, `value` | вставка значения в поле |
|
||||
| `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` |
|
||||
@@ -94,7 +109,7 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
| `status` | `"ok"` — антибот пройден и все действия выполнены, иначе `"err"` |
|
||||
| `code` | при `"err"`: причина, см. таблицу ниже |
|
||||
| `error` | при `"err"`: текст ошибки; для упавшего действия — `<type>: <текст>` |
|
||||
| `user_agent` | UA браузера, с которым проходили антибот (нужен клиенту вместе с `cf_clearance`) |
|
||||
| `user_agent` | родной UA браузера, с которым проходили антибот. Используйте его в своём HTTP-клиенте вместе с `cf_clearance` (см. [Usage.md](Usage.md)) |
|
||||
| `cookies` | все cookies контекста |
|
||||
| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки), `duration_ms` |
|
||||
| `html` | только при `"html": true` |
|
||||
@@ -103,7 +118,7 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
|
||||
| `code` | HTTP | Когда | Что ещё есть в ответе |
|
||||
|---|---|---|---|
|
||||
| `no_slots` | `503` + `Retry-After` | все браузеры заняты (`BROWSER_SLOTS`), запрос не ставится в очередь | — |
|
||||
| `no_slots` | `503` + `Retry-After` | только `/v1/solve`: все слоты браузеров заняты, или воркер не взял задачу за `V1_QUEUE_WAIT` секунд (задача отменяется) | — |
|
||||
| `request_failed` | `200` | ошибка до действий: навигация, прокси, неизвестный `captcha_type` | `actions: []` |
|
||||
| `challenge_failed` | `200` | антибот не пройден за отведённое время, действия не выполнялись | cookies, html, screenshot |
|
||||
| `action_failed` | `200` | упало действие, выполнение остановлено на нём | cookies, html, screenshot, `actions` до упавшего включительно |
|
||||
@@ -126,12 +141,92 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
|
||||
## Сессии
|
||||
|
||||
Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем
|
||||
(`extra/user_data_dir/<session_id>`): cookies, localStorage, отпечаток и `user_agent` сохраняются, повторно проходить Cloudflare
|
||||
(`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare
|
||||
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
|
||||
|
||||
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
|
||||
Сессии изолированы друг от друга: у каждой свой профиль 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.
|
||||
Каждое окно — отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare.
|
||||
Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка).
|
||||
+111
-24
@@ -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,27 +74,34 @@ 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`.
|
||||
- `API_CPUS` / `API_MEM_LIMIT` — потолок ресурсов контейнера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
|
||||
- `shm_size: 2gb` у worker — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»).
|
||||
- `API_PORT` — порт api на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`.
|
||||
- `WORKER_CPUS` / `WORKER_MEM_LIMIT` — потолок ресурсов воркера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
|
||||
При переборе памяти OOM-killer убивает процесс Chrome внутри контейнера, хост не страдает.
|
||||
- `RABBITMQ_PASSWORD` — пароль пользователя `robotex` в RabbitMQ (по умолчанию `robotex`; порт наружу не открыт).
|
||||
- `POSTGRES_PASSWORD` — пароль пользователя `robotex` в Postgres (по умолчанию `robotex`; порт наружу не открыт).
|
||||
RabbitMQ запускается с `consumer_timeout` 4 ч: задача может выполняться дольше 30 минут по умолчанию.
|
||||
- `stop_grace_period: 60s` у worker — при остановке он доделывает текущие задачи и закрывает окна.
|
||||
- `restart: unless-stopped`.
|
||||
- `.env` подключается, если существует (шаблон — `env.example`).
|
||||
|
||||
@@ -55,8 +109,16 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `BROWSER_SLOTS` | `10` | сколько Chrome работает параллельно; сверх — `503` |
|
||||
| `NO_SLOTS_RETRY_AFTER` | `5` | значение `Retry-After` (с) в ответе `503` |
|
||||
| `BROWSER_SLOTS` | `4` | worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди |
|
||||
| `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq |
|
||||
| `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди |
|
||||
| `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи |
|
||||
| `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` — полный дамп запроса/ответа (секреты клиента маскируются) |
|
||||
|
||||
## Нагрузка
|
||||
@@ -80,19 +142,44 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
|
||||
время растёт в десятки раз, а Cloudflare перестаёт пропускать (на 4 CPU и 10 параллельных — 6 из 10 застряли
|
||||
на челлендже).
|
||||
- Память — около **0,5–0,6 ГБ** на браузер.
|
||||
- Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `API_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`.
|
||||
Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env` (по умолчанию 10 — на ~10–12 ядер).
|
||||
- Сверх `BROWSER_SLOTS` запросы сразу получают `503` с `code: no_slots`, не ставятся в очередь.
|
||||
- Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `WORKER_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`.
|
||||
Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env`.
|
||||
- Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU.
|
||||
- Сверх `BROWSER_SLOTS` задачи v2 ждут в очереди, а `/v1/solve` сразу получает `503` с `code: no_slots`.
|
||||
|
||||
Повторить замер:
|
||||
Замеры выше сделаны на v1 (один сервис, Chrome в api).
|
||||
|
||||
### Замер 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
|
||||
python tests/loadtest.py --api http://127.0.0.1:8001 --levels 1 5 10
|
||||
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
|
||||
```
|
||||
|
||||
Параметры скрипта: `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`. Каждый запрос создаёт новый профиль
|
||||
в `extra/user_data_dir` — после прогона их стоит почистить.
|
||||
Параметры скрипта: `--mode v1|v2`, `--api-key`, `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`.
|
||||
Каждый запрос создаёт новый профиль в `extra/user_data_dir`; воркер удалит их через `PROFILE_TTL_DAYS`.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
@@ -104,4 +191,4 @@ api.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Таймауты прокси должны быть не меньше `timeout` запроса (по умолчанию 120 с).
|
||||
Таймауты прокси для `/v1/solve` должны быть не меньше `timeout` запроса (по умолчанию 120 с); запросы v2 короткие.
|
||||
@@ -6,4 +6,4 @@
|
||||
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет действия и возвращает cookies (включая `cf_clearance`),
|
||||
User-Agent и, по запросу, HTML. С этими cookies и UA клиент продолжает парсинг своим HTTP-клиентом.
|
||||
|
||||
Описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
|
||||
Как пользоваться API — [Usage.md](Usage.md), описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
|
||||
+138
@@ -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
|
||||
```
|
||||
@@ -1,4 +1,6 @@
|
||||
API_PORT=
|
||||
API_CPUS=1
|
||||
API_MEM_LIMIT=1g
|
||||
WORKER_CPUS=1
|
||||
WORKER_MEM_LIMIT=1g
|
||||
BROWSER_SLOTS=1
|
||||
RABBITMQ_PASSWORD=
|
||||
POSTGRES_PASSWORD=
|
||||
+14
-4
@@ -1,9 +1,19 @@
|
||||
# Project Env
|
||||
# порт, CPU и память контейнера (docker-compose)
|
||||
# порт api на хосте (docker-compose)
|
||||
API_PORT=8000
|
||||
API_CPUS=4
|
||||
API_MEM_LIMIT=8g
|
||||
# сколько Chrome параллельно, ≈ числу ядер (docs/Deployment.md)
|
||||
# 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
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
pythonpath = src client/src
|
||||
testpaths = tests
|
||||
addopts = -m "not browser"
|
||||
markers =
|
||||
browser: настоящий Chrome и сеть, запускать в Docker: make test-browser
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
+3
-1
@@ -23,8 +23,10 @@ async def click_and_race(page, element, challenge_locator, timeout=30_000):
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
# challenge_locator должен совпадать только с открытым окном челленджа: элементы челленджа могут быть в DOM
|
||||
# заранее (reCAPTCHA держит их за экраном), поэтому ждём видимости, а не появления
|
||||
challenge_task = asyncio.create_task(
|
||||
challenge_locator.wait_for(state="attached", timeout=timeout)
|
||||
challenge_locator.first.wait_for(state="visible", timeout=timeout)
|
||||
)
|
||||
|
||||
await element.click()
|
||||
|
||||
+41
-21
@@ -124,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))
|
||||
@@ -195,9 +195,12 @@ class CloudflareSolver(Solver):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from patchright.async_api import Page
|
||||
@@ -6,12 +5,26 @@ from patchright.async_api import Page
|
||||
from captcha.base import Captcha
|
||||
|
||||
from ..base import Detector, ChallengeInfo, Solver, AntiBotProvider, click_and_race
|
||||
from ..base import Stage
|
||||
from ..base import Stage, PWTimeoutError
|
||||
from ..registry import register
|
||||
from ..exceptions import IframeLoadError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# iframe окна с картинками (bframe) вставляется в DOM вместе с чекбоксом, но его контейнер стоит за экраном:
|
||||
# style="visibility: hidden; ... top: -10000px". Когда выпадает челлендж, контейнер переезжает на экран и
|
||||
# становится видимым — локатор совпадает только с открытым окном
|
||||
INTERACTIVE_MARKER_SELECTOR = (
|
||||
"xpath=//iframe[contains(@src, '/bframe')][not(ancestor::div[contains(@style, 'top: -10000px')])]"
|
||||
)
|
||||
|
||||
# признак reCAPTCHA, который есть уже в исходном HTML, до загрузки iframe с чекбоксом (он грузится долго):
|
||||
# скрипт api.js / enterprise.js, контейнер .g-recaptcha (у render=explicit его может не быть) или сам iframe.
|
||||
# #recaptcha-token не подходит — он внутри iframe чекбокса, с основной страницы его не видно
|
||||
DETECT_MARKER_SELECTOR = (
|
||||
"script[src*='/recaptcha/api.js'], script[src*='/recaptcha/enterprise.js'], "
|
||||
".g-recaptcha, iframe[title='reCAPTCHA']"
|
||||
)
|
||||
|
||||
JS_CHALLENGE_SUBMIT = """(token) => {
|
||||
const clients = window.___grecaptcha_cfg && window.___grecaptcha_cfg.clients;
|
||||
@@ -42,8 +55,8 @@ class ReCaptchaSolver(Solver):
|
||||
self.captcha_solver: Captcha = self._opt["captcha_solver"]
|
||||
|
||||
async def form_submit(self, token: str) -> bool:
|
||||
# noinspection UnnecessaryLabelJS
|
||||
return await self.page.evaluate(f"mw:({JS_CHALLENGE_SUBMIT})({json.dumps(token)})")
|
||||
# ___grecaptcha_cfg живёт в основном мире страницы, а patchright по умолчанию выполняет скрипт в изолированном
|
||||
return await self.page.evaluate(JS_CHALLENGE_SUBMIT, token, isolated_context=False)
|
||||
|
||||
async def solve(self, page: Page, info: ChallengeInfo):
|
||||
self.page = page
|
||||
@@ -55,18 +68,22 @@ class ReCaptchaSolver(Solver):
|
||||
log.debug("Нужно тыкнуть чекбокс")
|
||||
checkbox_selector = "#recaptcha-anchor"
|
||||
element = page.frame_locator("iframe[title='reCAPTCHA']").locator(checkbox_selector)
|
||||
# TODO: маркер всегда присутствует
|
||||
interactive_marker_selector = "//div[@class='g-recaptcha-bubble-arrow']/..//iframe"
|
||||
challenge_locator = page.locator(interactive_marker_selector)
|
||||
challenge_locator = page.locator(INTERACTIVE_MARKER_SELECTOR)
|
||||
result = await click_and_race(page, element, challenge_locator)
|
||||
log.debug(f"Ожидание завершено: {result}")
|
||||
log.debug("Возврат к детекту")
|
||||
return
|
||||
else:
|
||||
log.debug("Нужно решать капчу. Закрою окошко с картинками.")
|
||||
# полупрозрачная подложка на весь экран (position: fixed), клик по ней закрывает окно. В её центре лежит
|
||||
# само окно с картинками и перехватывает клик — поэтому кликаем в угол
|
||||
element = page.locator("xpath=//div[@class='g-recaptcha-bubble-arrow']/../div[1]")
|
||||
if await element.is_visible():
|
||||
await element.click()
|
||||
try:
|
||||
await element.click(position={"x": 1, "y": 1}, timeout=5_000)
|
||||
except PWTimeoutError:
|
||||
# не закрылось — не страшно, токен уходит через callback
|
||||
log.debug("Окошко с картинками закрыть не удалось")
|
||||
|
||||
log.debug("Решаем капчу...")
|
||||
assert info.detect_locator
|
||||
@@ -87,10 +104,9 @@ class ReCaptchaDetector(Detector):
|
||||
log.debug("ReCaptchaDetector: попытка определить есть ли преграда")
|
||||
info.meta = {}
|
||||
checkbox_selector = "#recaptcha-anchor"
|
||||
interactive_marker_selector = "//div[@class='g-recaptcha-bubble-arrow']/..//iframe"
|
||||
|
||||
# попытка определить что мы вообще на странице блокировки
|
||||
marker_selector = info.detect_locator or "#captcha-form"
|
||||
marker_selector = info.detect_locator or DETECT_MARKER_SELECTOR
|
||||
info.detect_locator = marker_selector
|
||||
|
||||
log.debug(f"ищем по detect: {marker_selector} success: {info.success_locator}")
|
||||
@@ -111,7 +127,7 @@ class ReCaptchaDetector(Detector):
|
||||
|
||||
log.debug("Вижу что это страница с проверкой. Определяю состояние.")
|
||||
iframe = page.frame_locator("iframe[title='reCAPTCHA']")
|
||||
# ожидаем когда будет загружен чекбокс
|
||||
# маркер есть — значит капча будет, ждём пока догрузится iframe с чекбоксом
|
||||
|
||||
info.stage = Stage.INTERSTITIAL
|
||||
checkbox = iframe.locator(checkbox_selector)
|
||||
@@ -126,7 +142,7 @@ class ReCaptchaDetector(Detector):
|
||||
log.debug("Чек бокс найден")
|
||||
info.stage = Stage.CHECKBOX
|
||||
|
||||
if await page.locator(interactive_marker_selector).count() > 0:
|
||||
if await page.locator(INTERACTIVE_MARKER_SELECTOR).first.is_visible():
|
||||
log.debug("Нужно решать капчу")
|
||||
# нужно решение
|
||||
info.stage = Stage.INTERACTIVE
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
from patchright.async_api import Page
|
||||
|
||||
from antibot.humanize import typing as human_typing
|
||||
from antibot.orchestrator import pass_challenges
|
||||
from captcha import load_captcha
|
||||
from api.schemas import ActionRequest
|
||||
@@ -31,6 +33,12 @@ async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
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:
|
||||
captcha_solver = load_captcha(captcha_key, act.value)
|
||||
@@ -71,6 +79,13 @@ async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
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:
|
||||
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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)
|
||||
@@ -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"]))),
|
||||
}
|
||||
+45
-2
@@ -1,20 +1,24 @@
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
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": (),
|
||||
}
|
||||
@@ -66,7 +70,6 @@ class SolveRequest(BaseModel):
|
||||
url: 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]}]
|
||||
|
||||
@@ -89,6 +92,17 @@ class SolveRequest(BaseModel):
|
||||
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:
|
||||
@@ -133,3 +147,32 @@ class SolveResponse(BaseModel):
|
||||
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
|
||||
@@ -30,6 +30,7 @@ class Captcha:
|
||||
return resp
|
||||
|
||||
async def turnstile(self, url, site_key):
|
||||
# TODO: not finished!
|
||||
resp = await self._run(self._solver.turnstile, site_key, url)
|
||||
return resp
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ class SolverReCaptcha(Captcha):
|
||||
:param page:
|
||||
:return:
|
||||
"""
|
||||
div_container = page.locator(selector)
|
||||
await div_container.wait_for()
|
||||
# selector может указывать на невидимый элемент (script) и совпадать с несколькими
|
||||
await page.locator(selector).first.wait_for(state="attached")
|
||||
|
||||
site_key_input_element = page.locator("iframe[title='reCAPTCHA']")
|
||||
src_value = await site_key_input_element.get_attribute("src")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Postgres (#4, #17): клиенты с API-ключами и журнал работы — окна браузера и запросы.
|
||||
|
||||
Общий для api и worker. Схема создаётся при старте сервиса (`init_db`). `DATABASE_URL` —
|
||||
`postgresql+asyncpg://...`; в тестах — `sqlite+aiosqlite://...`.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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)))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
|
||||
+149
-199
@@ -1,252 +1,202 @@
|
||||
"""
|
||||
Сервис 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 base64
|
||||
import json
|
||||
import logging
|
||||
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 Response
|
||||
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
from patchright.async_api import Page, ProxySettings
|
||||
from fastapi.security import APIKeyHeader
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
from api import actions # noqa: E402 — модули ниже логируют при импорте, конфиг логов должен быть раньше
|
||||
from api.geoip import resolve_geo # noqa: E402
|
||||
from api.schemas import SolveRequest, SolveResponse # noqa: E402
|
||||
from engine import AsyncStealthySession # noqa: E402
|
||||
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__)
|
||||
|
||||
# сколько Chrome запускается параллельно; подбирать ≈ числу ядер (см. docs/Deployment.md)
|
||||
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "10"))
|
||||
RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5")
|
||||
_slots = asyncio.Semaphore(BROWSER_SLOTS)
|
||||
# сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503 no_slots
|
||||
V1_QUEUE_WAIT = float(os.getenv("V1_QUEUE_WAIT", "30"))
|
||||
POLL_INTERVAL = 0.2
|
||||
|
||||
USER_DATA_ROOT = (Path(__file__).parent / "../extra/user_data_dir").resolve()
|
||||
SESSION_SETTINGS = "robotex_session.json"
|
||||
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 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}"
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def safe_request_dump(payload: SolveRequest) -> str:
|
||||
"""Запрос для лога без секретов клиента: креды прокси, ключ 2captcha, вводимые значения, cookies."""
|
||||
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"] == "fill" and "value" in act:
|
||||
act["value"] = "***"
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
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
|
||||
|
||||
|
||||
def safe_response_dump(result: dict) -> str:
|
||||
"""Ответ для лога: без значений cookies, html и скриншота."""
|
||||
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)
|
||||
ClientDep = Annotated[Client, Depends(current_client)]
|
||||
|
||||
NO_SLOTS = {"status": "err", "code": "no_slots", "error": "No slots available"}
|
||||
|
||||
|
||||
def session_dir(session_id: str | None) -> tuple[str, Path]:
|
||||
"""Каталог профиля по session_id из cookie. Принимается только UUID — иначе (в т.ч. попытка
|
||||
подсунуть путь вида ../..) выдаётся новая сессия."""
|
||||
try:
|
||||
session_id = str(uuid.UUID(session_id)) if session_id else None
|
||||
except ValueError:
|
||||
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 = None
|
||||
session_id = session_id or str(uuid.uuid4())
|
||||
|
||||
user_data_dir = (USER_DATA_ROOT / session_id).resolve()
|
||||
if user_data_dir.parent != USER_DATA_ROOT:
|
||||
raise ValueError("invalid session path")
|
||||
return session_id, user_data_dir
|
||||
session_id = session_id or new_session_id()
|
||||
response.set_cookie("session_id", session_id)
|
||||
return session_id
|
||||
|
||||
|
||||
def load_session_settings(user_data_dir: Path) -> dict:
|
||||
try:
|
||||
return json.loads((user_data_dir / SESSION_SETTINGS).read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
async def dispatch(message) -> None:
|
||||
# pika блокирующий — отправка в пуле потоков, чтобы не останавливать event loop
|
||||
await run_in_threadpool(queue.broker.enqueue, message)
|
||||
|
||||
|
||||
def save_session_settings(user_data_dir: Path, settings: dict) -> None:
|
||||
(user_data_dir / SESSION_SETTINGS).write_text(json.dumps(settings, ensure_ascii=False))
|
||||
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
|
||||
|
||||
|
||||
def make_page_action(payload: SolveRequest):
|
||||
"""Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий
|
||||
(включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже
|
||||
навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что
|
||||
вернёт fetch() (см. engine/stealthy.py).
|
||||
|
||||
Непройденный антибот не бросается наружу, а возвращается в 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
|
||||
async def free_slots() -> int:
|
||||
return sum(w["free_slots"] for w in await store.workers())
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
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("/")
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "browser_slots": BROWSER_SLOTS, "free_slots": _slots._value} # noqa
|
||||
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("/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(
|
||||
payload: SolveRequest,
|
||||
response: Response,
|
||||
session_id: Annotated[str | None, Cookie()] = None
|
||||
client: ClientDep,
|
||||
session_id: Annotated[str | None, Cookie()] = None,
|
||||
):
|
||||
# TODO: добавить поддержку api ключей (#4)
|
||||
"""Синхронный контракт 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()
|
||||
|
||||
if _slots.locked():
|
||||
logger.warning("no free browser slots (%d), rejecting %s", BROWSER_SLOTS, payload.url)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
headers={"Retry-After": RETRY_AFTER},
|
||||
content={"status": "err", "code": "no_slots", "error": "No slots available"},
|
||||
)
|
||||
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)
|
||||
|
||||
async with _slots:
|
||||
session_id, user_data_dir = session_dir(session_id)
|
||||
response.set_cookie("session_id", session_id)
|
||||
logger.info("Incoming request [%s]: %s", session_id, payload.url)
|
||||
logger.debug("Incoming payload [%s]:\n%s", session_id, safe_request_dump(payload))
|
||||
|
||||
try:
|
||||
user_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# user_agent сессии сохраняется рядом с профилем: при повторном запросе с тем же
|
||||
# session_id без user_agent отпечаток не должен меняться
|
||||
settings = load_session_settings(user_data_dir)
|
||||
if payload.user_agent:
|
||||
settings["user_agent"] = payload.user_agent
|
||||
save_session_settings(user_data_dir, settings)
|
||||
user_agent = settings.get("user_agent")
|
||||
|
||||
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
|
||||
locale = geo.get("locale") if geo else None
|
||||
|
||||
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,
|
||||
useragent=user_agent,
|
||||
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:
|
||||
logger.exception("request failed [%s]", session_id)
|
||||
result_payload = {
|
||||
"status": "err",
|
||||
"code": "request_failed",
|
||||
"error": str(e) or type(e).__name__,
|
||||
}
|
||||
else:
|
||||
failed = [a for a in result["actions"] if a.get("ok") is False]
|
||||
result_payload = {
|
||||
"status": "ok",
|
||||
"user_agent": result["user_agent"],
|
||||
"cookies": result["cookies"],
|
||||
"actions": result["actions"],
|
||||
}
|
||||
if result["challenge_error"]:
|
||||
result_payload.update(status="err", code="challenge_failed", error=result["challenge_error"])
|
||||
elif failed:
|
||||
result_payload.update(
|
||||
status="err", code="action_failed",
|
||||
error=f"{failed[0]['type']}: {failed[0].get('result')}",
|
||||
)
|
||||
if payload.html:
|
||||
result_payload["html"] = result["html"]
|
||||
if payload.screenshot:
|
||||
result_payload["screenshot"] = result["screenshot"]
|
||||
|
||||
result_payload["duration_ms"] = int((time.monotonic() - started) * 1000)
|
||||
logger.info(
|
||||
"Outgoing [%s]: %s %s %dms", session_id, result_payload["status"],
|
||||
result_payload.get("error", ""), result_payload["duration_ms"],
|
||||
)
|
||||
logger.debug("Outgoing payload:\n%s", safe_response_dump(result_payload))
|
||||
return result_payload
|
||||
result.setdefault("actions", [])
|
||||
result["duration_ms"] = int((time.monotonic() - started) * 1000)
|
||||
return result
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Общее для сервисов api и worker (#78): хранилище задач в Redis, брокер dramatiq, сессии.
|
||||
|
||||
Пакет не зависит от браузера: образ api собирается без Chrome и Patchright.
|
||||
"""
|
||||
@@ -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={})
|
||||
@@ -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())
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Сервис worker (#78): берёт задачи из очереди RabbitMQ, выполняет их в Chrome и кладёт результат в Redis.
|
||||
|
||||
Запуск: `dramatiq worker.actors --processes 1 --threads N` под Xvfb (см. Dockerfile). Процесс — строго
|
||||
один: открытые окна браузера живут в его памяти.
|
||||
"""
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
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
|
||||
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
# под dramatiq логирование уже настроено им самим (уровень INFO) и basicConfig ничего не делает — уровень явно
|
||||
logging.getLogger().setLevel(LOG_LEVEL)
|
||||
|
||||
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))
|
||||
@@ -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
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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 # посимвольно, с паузами
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# брокер dramatiq в памяти: tasks.broker создаётся при импорте
|
||||
os.environ.setdefault("RABBITMQ_URL", "stub://")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dev": {
|
||||
"api_key": "rbx_...",
|
||||
"proxy": "http://user:pass@host:port",
|
||||
"captcha_key": "<ключ 2captcha>"
|
||||
}
|
||||
}
|
||||
+46
-11
@@ -1,8 +1,9 @@
|
||||
"""Нагрузочный тест /v1/solve: N параллельных запросов на каждом уровне + пиковое потребление
|
||||
контейнера по `docker stats`.
|
||||
"""Нагрузочный тест: N параллельных запросов на каждом уровне (каждый — в своей сессии) + пиковое
|
||||
потребление контейнера воркера по `docker stats`.
|
||||
|
||||
docker compose --profile loadtest up -d
|
||||
python tests/loadtest.py --levels 1 5 10 15
|
||||
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,
|
||||
упало действие, исключение до действий.
|
||||
@@ -10,6 +11,7 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -81,12 +83,43 @@ class StatsSampler(threading.Thread):
|
||||
self.stop_event.wait(1)
|
||||
|
||||
|
||||
async def one(client: httpx.AsyncClient, api: str, payload: dict) -> tuple[str, float]:
|
||||
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()
|
||||
try:
|
||||
resp = await client.post(f"{api}/v1/solve", json=payload)
|
||||
except Exception as e:
|
||||
resp = e
|
||||
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
|
||||
|
||||
|
||||
@@ -94,8 +127,7 @@ async def run_level(n: int, args) -> None:
|
||||
sampler = StatsSampler(args.container)
|
||||
sampler.start()
|
||||
t0 = time.monotonic()
|
||||
async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False) as client:
|
||||
results = await asyncio.gather(*(one(client, args.api, build_payload(args)) for _ in range(n)))
|
||||
results = await asyncio.gather(*(one(args, build_payload(args)) for _ in range(n)))
|
||||
wall = time.monotonic() - t0
|
||||
sampler.stop_event.set()
|
||||
sampler.join()
|
||||
@@ -120,7 +152,10 @@ async def main():
|
||||
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("--container", default="webroboapi-api-1")
|
||||
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)
|
||||
|
||||
@@ -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])
|
||||
+47
-14
@@ -1,4 +1,6 @@
|
||||
# Test your FastAPI endpoints
|
||||
# Секреты (api_key, proxy, captcha_key) — в tests/http-client.private.env.json (не в git, шаблон —
|
||||
# http-client.private.env.example.json); окружение dev выбирается в PyCharm над запросом.
|
||||
|
||||
GET http://127.0.0.1:8000/
|
||||
Accept: application/json
|
||||
@@ -13,7 +15,7 @@ Accept: application/json
|
||||
POST http://127.0.0.1:8000/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: <key>
|
||||
X-API-Key: {{api_key}}
|
||||
|
||||
|
||||
{
|
||||
@@ -36,7 +38,7 @@ X-API-Key: <key>
|
||||
POST http://127.0.0.1:8000/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: <key>
|
||||
X-API-Key: {{api_key}}
|
||||
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
|
||||
|
||||
@@ -53,7 +55,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
POST http://127.0.0.1:8000/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: <key>
|
||||
X-API-Key: {{api_key}}
|
||||
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
|
||||
|
||||
@@ -72,7 +74,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
POST http://127.0.0.1:8000/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: <key>
|
||||
X-API-Key: {{api_key}}
|
||||
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
|
||||
|
||||
@@ -91,7 +93,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
|
||||
POST http://127.0.0.1:8000/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: <key>
|
||||
X-API-Key: {{api_key}}
|
||||
|
||||
|
||||
{
|
||||
@@ -109,19 +111,50 @@ 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>
|
||||
X-API-Key: {{api_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": "{{proxy}}",
|
||||
"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: {{api_key}}
|
||||
|
||||
###
|
||||
# @timeout 600
|
||||
POST http://localhost:8010/v1/solve
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{api_key}}
|
||||
|
||||
{
|
||||
"url": "https://lessons.zennolab.com/captchas/recaptcha/v2_nosubmit.php?level=low",
|
||||
"proxy": "{{proxy}}",
|
||||
"actions": [
|
||||
{
|
||||
"type": "inner_text",
|
||||
"selector": "//div[@id='main']/div/h2"
|
||||
}
|
||||
],
|
||||
"captcha": "{{captcha_key}}",
|
||||
"captcha_type": "recaptcha",
|
||||
"captcha_success_locator": "//div[@id='main']"
|
||||
}
|
||||
|
||||
###
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)) == []
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}]
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user