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

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

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

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

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ __pycache__/
wheels/ wheels/
docs/*.bkp docs/*.bkp
docs/*.dtmp docs/*.dtmp
build/
+34 -2
View File
@@ -3,11 +3,12 @@ PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
WHEELS_DIR := wheels WHEELS_DIR := wheels
REQUIREMENTS := requirements.txt REQUIREMENTS := requirements.txt
ver ?= latest
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.PHONY: install freeze wheels sync sync-offline clean help venv .PHONY: install freeze wheels sync sync-offline clean help venv build release
help: help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -16,6 +17,8 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения" @echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@echo "make clean - удалить $(WHEELS_DIR)/" @echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make build [ver=latest] - собрать docker-образ robotex:<ver>"
@echo "make release ver=<ver> - собрать образ и пакет для деплоя в build/<ver>/ (compose, .env, образ .tar.gz)"
venv: venv:
@test -d $(VENV) || python3 -m venv $(VENV) @test -d $(VENV) || python3 -m venv $(VENV)
@@ -46,6 +49,35 @@ wheels: venv
clean: clean:
rm -rf $(WHEELS_DIR) rm -rf $(WHEELS_DIR)
build:
@echo "build an application"
docker build -t robotex:$(ver) .
RELEASE_DIR := build/$(ver)
ifneq ($(filter release,$(MAKECMDGOALS)),)
ifeq ($(ver),latest)
$(error Укажите версию: make release ver=v0.0.2)
endif
endif
release: build
@echo "make release $(ver) -> $(RELEASE_DIR)/"
@mkdir -p $(RELEASE_DIR)
sed 's|image: robotex:.*|image: robotex:$(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
@printf '%s\n' \
'# Robotex $(ver)' \
'' \
'```bash' \
'docker load -i robotex-$(ver).tar.gz' \
'docker compose up -d' \
'```' \
'' \
'Настройки (порт, CPU, память) — в .env.' > $(RELEASE_DIR)/README.md
@echo "Готово: $(RELEASE_DIR)/"
# позволяет передавать имя пакета как аргумент: make install redis # позволяет передавать имя пакета как аргумент: make install redis
%: %:
@: @:
+81 -88
View File
@@ -1,83 +1,94 @@
# Robotex # Robotex
Browser automation API with built-in anti-bot stealth. Send a URL and a sequence of declarative actions — fill a field, Browser automation API with built-in anti-bot stealth. Send a URL and a sequence of declarative actions — fill a field,
click, wait for an element — and get back the rendered page, including sites behind Cloudflare-style JS challenges. click, wait for an element — and get back cookies, the rendered page and, optionally, a screenshot, including sites
behind Cloudflare-style JS challenges.
## Status ## Status
Early MVP (v0), under active development. Single browser engine, no queue, no billing yet — see [Roadmap](#roadmap). Early MVP (v0), under active development. Single browser engine, no queue, 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)
## Architecture ## Architecture
``` ```
Client → Caddy (TLS) → FastAPI → API-key auth → semaphore acquire Client → Caddy (TLS, on the host) → FastAPI POST /v1/solve → browser slot (semaphore, 503 if none)
│ │
▼ ▼
Chrome (Patchright) context Chrome (Patchright), profile per session_id
(run actions, collect result) pass anti-bot → run actions → collect result
│ │
▼ ▼
release semaphore → response release slot → response
``` ```
- **Engine**: real Google Chrome (`channel="chrome"`) driven via [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python) - **Engine**: real Google Chrome (`channel="chrome"`) driven via [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python)
(undetected Playwright fork), headful under Xvfb. Firefox is not supported yet. (undetected Playwright fork), headful under Xvfb. Browser session wrapper ported from scrapling. Firefox is not
- **API**: FastAPI, single versioned endpoint (`POST /v1/solve`). supported yet.
- **Concurrency**: bounded by an `asyncio.Semaphore` over browser contexts — no external job queue in v1. Simpler to - **API**: FastAPI, single versioned endpoint (`POST /v1/solve`) plus `GET /health`.
run, hard ceiling on throughput per instance (see Roadmap for scaling out). - **Concurrency**: bounded by an `asyncio.Semaphore` of `BROWSER_SLOTS` (env, default 10) — no external job queue in
- **Auth**: per-client API key (`X-API-Key` header), checked against a SQLite table. (SQLAlchemy ORM) v1. When all slots are busy the request gets `503` with `Retry-After` immediately, it is not queued.
- **Logging**: every request's outcome (success/failure/duration) is persisted — this becomes the success-rate data used - **Anti-bot**: detect → solve → verify loop for Cloudflare Turnstile and reCAPTCHA; captchas inside pages (Turnstile,
to evaluate the service and talk to clients about reliability. reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
- **Proxies**: not managed by the service in v1 — the client supplies their own proxy per request if they need one. - **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
- **Deployment**: single Docker container behind a Caddy reverse proxy — see [Docker](#docker) and timezone are derived from the proxy's exit IP.
- **Sessions**: `session_id` cookie → persistent Chrome profile (cookies, local storage, user agent) on disk.
- **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
[docs/Deployment.md](docs/Deployment.md). [docs/Deployment.md](docs/Deployment.md).
- **Canvas fingerprint** по дизайну генерируется каждую сессию новый. - **Canvas fingerprint** is generated anew for every session by design.
### Project layout ### Project layout
``` ```
src/ src/
main.py # FastAPI app, POST /v1/solve main.py # FastAPI app: /v1/solve, /health, slots, sessions, response
api/ api/
actions.py # fill / click / wait_for execution schemas.py # SolveRequest / ActionRequest / SolveResponse + validation
schemas.py # SolveRequest / Action models actions.py # action registry and execution
geoip.py # proxy → locale / timezone geoip.py # proxy → locale / timezone
engine/ engine/ # Chrome session wrapper (ported from scrapling)
stealthy.py # AsyncStealthySession (Patchright + Chrome) stealthy.py # AsyncStealthySession
session.py # browser / context launch options session.py # browser / context launch options
page_pool.py # page pool navigation.py, page_pool.py, validators.py, proxy_rotation.py, ad_domains.py, ...
antibot/ # anti-bot detection & bypass orchestrator (Cloudflare, reCAPTCHA) antibot/
captcha/ # captcha solvers (Turnstile, reCAPTCHA, hCaptcha) orchestrator.py # detect → solve → verify loop
registry.py, base.py
known_providers/ # cloudflare.py, recaptcha.py
humanize.py # human-like mouse moves and typing
captcha/ # 2captcha solvers: Turnstile, reCAPTCHA, hCaptcha
docs/ # API, deployment, anti-bot notes
tests/ tests/
wheels/ # whl библиотеки для оффлайн установки под Ubuntu Noble 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
docker-compose.yml docker-compose.yml # local / load testing
.dockerignore docker-compose.prod.yml # template for releases (make release)
requirements.txt Makefile
env.example
``` ```
## API (draft) ## API
``` ```
POST /v1/solve POST /v1/solve
X-API-Key: <key> X-API-Key: <key>
{ {
"captcha": "token", "url": "https://freelancehunt.com/projects",
"url": "https://example.com/login", "proxy": "http://user:pass@host:port",
"captcha_type": "cloudflare",
"actions": [ "actions": [
{"type": "fill", "selector": "#email", "value": "user@example.com"}, {"type": "wait_for", "selector": "a[href*=\"/project/\"]", "timeout": 60}
{"type": "fill", "selector": "#password", "value": "..."},
{"type": "mouse_move", "selector": "#submit"},
{"type": "click", "selector": "#submit"},
{"type": "wait_for", "selector": ".dashboard", "timeout": 30}
], ],
"proxy": "socks5://user:pass@host:port", "html": true
"screen": "1280x920",
"user_agent": "Mozilla/5.0 (...) Chrome/...",
"timeout": 120
} }
``` ```
@@ -85,46 +96,18 @@ Response:
```json ```json
{ {
"ok": false, "status": "ok",
"context": "000000", "user_agent": "Mozilla/5.0 (...) Chrome/155.0.0.0 Safari/537.36",
"error": "action_error", "cookies": [{"name": "cf_clearance", "value": "..."}],
"html": "<html>...</html>", "actions": [{"type": "wait_for", "ok": true, "duration_ms": 14}],
"cookies": [ "html": "<head>...</head><body>...</body>",
{ "duration_ms": 6405
"name": "session",
"value": "..."
}
],
"actions_result": [
{
"type": "fill",
"ok": true
},
{
"type": "fill",
"ok": true
},
{
"type": "click",
"ok": true
},
{
"type": "wait_for",
"ok": false,
"error": "Element not located"
}
]
} }
``` ```
On failure, `status` is `"error"` and `error` carries a machine-readable reason (`selector_not_found`, `timeout`, On failure `status` is `"err"` with a machine-readable `code` (`no_slots`, `request_failed`, `challenge_failed`,
`navigation_failed`, ...). `action_failed`) and a human-readable `error`. Full reference — fields, action types, errors, sessions:
[docs/API.md](docs/API.md).
Fill команда включает 3 действия
1. `{"type": "mouse_move", "selector": "#email"},` - навести мышку на элемент
2. `{"type": "click"},` - клик в текущие координаты
3. `{"type": "typing", "value": "user@example.com"},` - набор текста
## Roadmap ## Roadmap
@@ -132,7 +115,7 @@ Deliberately out of scope for v1:
- Job queue (Redis/RabbitMQ) + multiple worker VPS for horizontal scaling - Job queue (Redis/RabbitMQ) + multiple worker VPS for horizontal scaling
- Real billing/usage plans beyond a flat API-key check - Real billing/usage plans beyond a flat API-key check
- Broader action vocabulary beyond fill/click/wait_for - Broader action vocabulary
- Second engine (Firefox, e.g. Camoufox) for targets where the Chrome fingerprint doesn't fit - Second engine (Firefox, e.g. Camoufox) for targets where the Chrome fingerprint doesn't fit
## Local development ## Local development
@@ -144,6 +127,8 @@ make sync-offline
cd src && uvicorn main:app --reload cd src && uvicorn main:app --reload
``` ```
Chrome runs headful, so a display is required (locally — your desktop, in Docker — Xvfb).
## Docker ## Docker
```bash ```bash
@@ -151,5 +136,13 @@ docker compose up -d --build
``` ```
The API listens on `http://localhost:8000`. Browser profiles (`extra/user_data_dir`) and the GeoIP cache live in the The API listens on `http://localhost:8000`. Browser profiles (`extra/user_data_dir`) and the GeoIP cache live in the
`extra` named volume, so sessions survive container restarts. Details (Xvfb, tini, `/dev/shm`) — `extra` named volume, so sessions survive container restarts.
[docs/Deployment.md](docs/Deployment.md).
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)
```
Details (Xvfb, tini, `/dev/shm`, env settings, load measurements) — [docs/Deployment.md](docs/Deployment.md).
-15
View File
@@ -1,15 +0,0 @@
# Список задач
## Срочные и Важные
-[ ] не удается пройти cf_turnstile
## Не срочные и Важные
-[ ] добавить пакет обертку для python
## Срочные и Не важные
## Не срочные и Не срочные
-[ ]
+22
View File
@@ -0,0 +1,22 @@
services:
api:
image: robotex:v0.0.1
restart: unless-stopped
ports:
- "${API_PORT:-8000}:8000"
env_file:
- path: .env
required: false
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}
volumes:
api-data:
+14 -1
View File
@@ -1,7 +1,7 @@
services: services:
api: api:
build: . build: .
image: robotex-api image: robotex:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
@@ -12,6 +12,19 @@ services:
- extra:/app/extra - extra:/app/extra
# Chrome активно использует /dev/shm, дефолтных 64MB не хватает # Chrome активно использует /dev/shm, дефолтных 64MB не хватает
shm_size: "2gb" shm_size: "2gb"
# Потолок ресурсов: при переборе OOM-killer прибьёт процесс Chrome внутри контейнера,
# а не соседние сервисы на хосте. Подбирать под сервер вместе с BROWSER_SLOTS.
cpus: ${API_CPUS:-4}
mem_limit: ${API_MEM_LIMIT:-8g}
memswap_limit: ${API_MEM_LIMIT:-8g}
# HTTP-прокси для локальных тестов (exit-IP = IP хоста): docker compose --profile loadtest up -d
# из контейнера api доступен как http://proxy:8080
proxy:
image: gogost/gost:3.1.0
profiles: ["loadtest"]
restart: unless-stopped
command: ["-L", "http://:8080"]
volumes: volumes:
extra: extra:
+137
View File
@@ -0,0 +1,137 @@
# API
Основной эндпоинт: `POST /v1/solve`. Открывает страницу в Chrome (через прокси клиента, если он задан), при необходимости проходит
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и,
по запросу, HTML и скриншот.
`GET /health` (и `GET /`) — `{"status": "ok", "browser_slots": N, "free_slots": M}`.
> `X-API-Key` пока не проверяется ([#4](https://redmine.sokolab.xyz/issues/4)).
## Запрос
```http
POST /v1/solve
Content-Type: application/json
X-API-Key: <key>
Cookie: session_id=<id> # необязательно, см. «Сессии»
{
"url": "https://freelancehunt.com/projects",
"proxy": "http://user:pass@host:port",
"captcha_type": "cloudflare",
"actions": [
{"type": "wait_for", "selector": "a[href*=\"/project/\"]", "timeout": 60}
],
"html": true,
"timeout": 120
}
```
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `url` | string | — | **обязательно**. Страница, которую открываем |
| `proxy` | string | `null` | `scheme://[user:pass@]host:port`, схема `http`, `https` или `socks5` (Chrome не поддерживает socks5 с логином/паролем). По exit-IP прокси выставляются locale и timezone браузера; без прокси — настройки браузера по умолчанию |
| `actions` | array | — | **обязательно**, может быть пустым. Действия, см. ниже |
| `captcha_type` | string | `null` | провайдер антибота: `cloudflare` или `recaptcha`. Без него шаг антибота пропускается — для сайтов за Cloudflare обязательно |
| `captcha` | string | `null` | ключ сервиса распознавания (2captcha), если антибот не проходится без решения капчи |
| `captcha_detect_locator` | string | `null` | локатор, по которому определяется, что на странице антибот |
| `captcha_success_locator` | string | `null` | локатор, по которому определяется, что антибот пройден |
| `html` | bool | `false` | вернуть HTML страницы после выполнения действий |
| `screenshot` | bool | `false` | вернуть скриншот видимой области (PNG, base64) после действий — в том числе при ошибке действия или антибота |
| `timeout` | int, с | `60` | таймаут навигации |
| `user_agent` | string | `null` | свой User-Agent; запоминается в сессии (см. «Сессии»). Без него — UA сессии или UA установленного Chrome |
| `cookies` | array | `null` | cookies для установки до навигации, формат Playwright: `{name, value, domain, path, expires, httpOnly, secure, sameSite}` |
| `disable_resources` | bool | `false` | не грузить картинки, шрифты, медиа |
| `blocked_domains` | array | `null` | домены, запросы к которым блокируются |
| `retries` | int | `1` | попыток навигации |
| `retry_delay` | float, с | `1.0` | пауза между попытками |
| `wait` | int, мс | `0` | пауза после загрузки страницы |
| `google_search` | bool | `false` | выставить Referer как переход из поиска Google |
### Действия
Выполняются по порядку после прохождения антибота. Если антибот не пройден, действия не выполняются
(`code: challenge_failed`). На первой ошибке действия выполнение останавливается.
Обязательные поля проверяются до запуска браузера: неизвестный `type`, нет `selector`/`value`, `captcha` без ключа
в запросе, неверный `proxy` — ответ `422` с описанием в `detail` (стандартный формат FastAPI).
| `type` | Поля | Что делает |
|---|---|---|
| `wait_for` | `selector`, `timeout` (1–7200 с, по умолчанию 30) | ждёт появления элемента; при таймауте — ошибка действия |
| `click` | `selector` | клик по элементу |
| `submit` | `selector` | клик и ожидание навигации |
| `fill` | `selector`, `value` | вставка значения в поле |
| `mouse_move` | `selector` | наведение курсора на элемент; нет элемента — ошибка `selector not found` |
| `delay` | `value` (секунды строкой, 0–600) | пауза |
| `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` |
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` в запросе |
| `pass_challenge` | `selector` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия |
## Ответ
Формат один для всех случаев: `status` — `"ok"` или `"err"`, при ошибке — `code` и `error`. Поля без значения не
возвращаются.
Успех (антибот пройден, действия выполнены), HTTP `200`:
```json
{
"status": "ok",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) ... Chrome/155.0.0.0 Safari/537.36",
"cookies": [{"name": "cf_clearance", "value": "...", "domain": ".freelancehunt.com", "...": "..."}],
"actions": [
{"type": "wait_for", "ok": true, "duration_ms": 14}
],
"html": "<head>...</head><body>...</body>",
"duration_ms": 6405
}
```
| Поле | Описание |
|---|---|
| `status` | `"ok"` — антибот пройден и все действия выполнены, иначе `"err"` |
| `code` | при `"err"`: причина, см. таблицу ниже |
| `error` | при `"err"`: текст ошибки; для упавшего действия — `<type>: <текст>` |
| `user_agent` | UA браузера, с которым проходили антибот (нужен клиенту вместе с `cf_clearance`) |
| `cookies` | все cookies контекста |
| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки), `duration_ms` |
| `html` | только при `"html": true` |
| `screenshot` | только при `"screenshot": true`: PNG в base64 |
| `duration_ms` | общее время обработки запроса |
| `code` | HTTP | Когда | Что ещё есть в ответе |
|---|---|---|---|
| `no_slots` | `503` + `Retry-After` | все браузеры заняты (`BROWSER_SLOTS`), запрос не ставится в очередь | — |
| `request_failed` | `200` | ошибка до действий: навигация, прокси, неизвестный `captcha_type` | `actions: []` |
| `challenge_failed` | `200` | антибот не пройден за отведённое время, действия не выполнялись | cookies, html, screenshot |
| `action_failed` | `200` | упало действие, выполнение остановлено на нём | cookies, html, screenshot, `actions` до упавшего включительно |
Примеры:
```json
{"status": "err", "code": "action_failed", "error": "inner_html: selector not found: #missing",
"actions": [{"type": "inner_html", "ok": false, "result": "selector not found: #missing", "duration_ms": 26}], "...": "..."}
```
```json
{"status": "err", "code": "challenge_failed", "error": "cloudflare not passed: timeout (stage: checkbox)", "actions": [], "...": "..."}
```
```json
{"status": "err", "code": "no_slots", "error": "No slots available"}
```
## Сессии
Ответ ставит cookie `session_id` (UUID). Если передать её в следующем запросе, браузер откроется с тем же профилем
(`extra/user_data_dir/<session_id>`): cookies, localStorage, отпечаток и `user_agent` сохраняются, повторно проходить Cloudflare
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
## Производительность
Каждый запрос запускает отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare.
Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка).
+45
View File
@@ -46,9 +46,54 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
- `shm_size: 2gb` — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»). - `shm_size: 2gb` — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»).
- `API_PORT` — порт на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`. - `API_PORT` — порт на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`.
- `API_CPUS` / `API_MEM_LIMIT` — потолок ресурсов контейнера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
При переборе памяти OOM-killer убивает процесс Chrome внутри контейнера, хост не страдает.
- `restart: unless-stopped`. - `restart: unless-stopped`.
- `.env` подключается, если существует (шаблон — `env.example`). - `.env` подключается, если существует (шаблон — `env.example`).
## Переменные окружения приложения
| Переменная | По умолчанию | Описание |
|---|---|---|
| `BROWSER_SLOTS` | `10` | сколько Chrome работает параллельно; сверх — `503` |
| `NO_SLOTS_RETRY_AFTER` | `5` | значение `Retry-After` (с) в ответе `503` |
| `LOG_LEVEL` | `INFO` | `DEBUG` — полный дамп запроса/ответа (секреты клиента маскируются) |
## Нагрузка
Замер: `freelancehunt.com/projects` за Cloudflare Turnstile, `captcha_type: cloudflare`, ожидание списка проектов,
каждый запрос — новый профиль. Прокси — `gost` из профиля `loadtest`. Хост: 16 ядер, 31 ГБ.
| Лимит | Параллельно | Успешно | Медиана, с | Пик CPU | Пик RAM |
|---|---|---|---|---|---|
| 4 CPU | 1 | 1/1 | 5 | 3,5 ядра | 0,5 ГБ |
| 4 CPU | 5 | 3/5 | 68 | 4,1 | 2,8 ГБ |
| 4 CPU | 10 | 4/10 | 229 | 4,2 | 6,0 ГБ |
| 4 CPU | 15 | 3/15 | 261 | 4,2 | 6,1 ГБ |
| 12 CPU | 3 | 3/3 | 8 | 7,2 | 1,4 ГБ |
| 12 CPU | 5 | 4/5 | 8 | 12,1 | 2,4 ГБ |
| 12 CPU | 10 | 9/10 | 23 | 12,3 | 4,7 ГБ |
Выводы:
- Узкое место — **CPU**. Один Chrome на прохождении Turnstile съедает 2–3,5 ядра. Когда процессора не хватает,
время растёт в десятки раз, а Cloudflare перестаёт пропускать (на 4 CPU и 10 параллельных — 6 из 10 застряли
на челлендже).
- Память — около **0,5–0,6 ГБ** на браузер.
- Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `API_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`.
Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env` (по умолчанию 10 — на ~10–12 ядер).
- Сверх `BROWSER_SLOTS` запросы сразу получают `503` с `code: no_slots`, не ставятся в очередь.
Повторить замер:
```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
```
Параметры скрипта: `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`. Каждый запрос создаёт новый профиль
в `extra/user_data_dir` — после прогона их стоит почистить.
## Reverse proxy ## Reverse proxy
В проде контейнер ставится за Caddy (TLS). Пример `Caddyfile`: В проде контейнер ставится за Caddy (TLS). Пример `Caddyfile`:
+5 -56
View File
@@ -1,60 +1,9 @@
# WebRobo API # Robotex
Сервис позволяет отработать на странице JS и продолжить парсинг. Полезно для ботов, которым нужно авторизоваться на сайте или пройти капчу, там где требуется рендеринг JS. Сервис позволяет отработать на странице JS и продолжить парсинг. Полезно для ботов, которым нужно авторизоваться на сайте или пройти капчу, там где требуется рендеринг JS.
### Запрос и ответ Клиент присылает URL, список действий и, при необходимости, свой прокси. Сервис открывает страницу в настоящем Chrome, проходит
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет действия и возвращает cookies (включая `cf_clearance`),
User-Agent и, по запросу, HTML. С этими cookies и UA клиент продолжает парсинг своим HTTP-клиентом.
**Параметры запроса**: Описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
- URL - обязательно нужно указать с каким сайтом будем работать
- proxy - обязательно нужно задать прокси, система не станет палить свою инфраструктуру
- time_zone - временная зона
- locale - языковая настройка
- user_agent - User-Agent браузера Chrome, если не передать будет использован свой
- screen - размер окна, по умолчанию: 1280х920
- timeout - общий таймаут выполнения запроса, по умолчанию: 120
- captcha - токен сервиса распознавания, если требуется решить капчу на странице
- actions - перечисление списка действий, которые на странице нужно выполнить
Настройка actions:
- type - тип запроса из перечисления
- selector - css селектор
- value - значение для вставки
- timeout - время выполнения или ожидания
Доступные типы actions:
- wait_for - ожидание появления элемента
- selector
- timeout
- mouse_move - перемещение курсора
- selector
- click - клик
- selector - если не передан, то клик в текущей позиции
- fill - заполняет поля
- selector
- value - значение будет впечатано
Будет добавлено:
1. `{"type": "typing", "value": "user@example.com"},` - набор текста
2. `{"type": "innerhtml", "selector": "#email"},` - получить контент
3. `{"type": "script", "source": "console.log(\"hello world\")"},` - выполнить скрипт
**Ответ**:
- URL - итоговый урл из адресной строки на момент окончания работы
- user_agent - заголовок браузера
- screen - размер окна
- exc_time = время выполнения
- actions_result - отчет о выполненых действиях
- ok - выполнился ли этот запрос успешно
- error - для сообщения об ошибке
Результат отработки actions
- type - тип события
- ok - успешность события
- error - появляется, когда событие сработало с ошибкой
- message - появляется, для команд возвращающих результат
+4
View File
@@ -0,0 +1,4 @@
API_PORT=
API_CPUS=1
API_MEM_LIMIT=1g
BROWSER_SLOTS=1
+8
View File
@@ -1 +1,9 @@
# Project Env # Project Env
# порт, CPU и память контейнера (docker-compose)
API_PORT=8000
API_CPUS=4
API_MEM_LIMIT=8g
# сколько Chrome параллельно, ≈ числу ядер (docs/Deployment.md)
BROWSER_SLOTS=4
NO_SLOTS_RETRY_AFTER=5
LOG_LEVEL=INFO
-5
View File
@@ -85,7 +85,6 @@ async def move_mouse_human(
if i < steps: if i < steps:
px += random.uniform(-1.5, 1.5) px += random.uniform(-1.5, 1.5)
py += random.uniform(-1.5, 1.5) py += random.uniform(-1.5, 1.5)
print("Move:", px, py)
await page.mouse.move(px, py) await page.mouse.move(px, py)
await page.wait_for_timeout(step_delay_ms * random.uniform(0.7, 1.3)) await page.wait_for_timeout(step_delay_ms * random.uniform(0.7, 1.3))
@@ -103,13 +102,9 @@ async def click_human(
"""move_mouse_human + клик с человеческой задержкой между наведением и down/up, вместо """move_mouse_human + клик с человеческой задержкой между наведением и down/up, вместо
мгновенного page.mouse.click(x, y).""" мгновенного page.mouse.click(x, y)."""
await move_mouse_human(page, x, y, **move_kwargs) await move_mouse_human(page, x, y, **move_kwargs)
print('wait for timeout')
await page.wait_for_timeout(random.uniform(50, 180)) await page.wait_for_timeout(random.uniform(50, 180))
print('key down')
await page.mouse.down(button=button) await page.mouse.down(button=button)
print('sleep')
await page.wait_for_timeout(random.uniform(40, 99)) await page.wait_for_timeout(random.uniform(40, 99))
print('key up')
await page.mouse.up(button=button) await page.mouse.up(button=button)
+9 -8
View File
@@ -72,8 +72,6 @@ from ..registry import register
from ..humanize import wait_for_page_stability, wait_for_networkidle, click_human from ..humanize import wait_for_page_stability, wait_for_networkidle, click_human
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler())
class CloudflareDetector(Detector): class CloudflareDetector(Detector):
@@ -185,9 +183,12 @@ __CF_PATTERN__ = re.compile(r"^https://challenges\.cloudflare\.com/cdn-cgi/chall
class CloudflareSolver(Solver): class CloudflareSolver(Solver):
# опрос исчезновения маркера челленджа после клика / на interstitial
POLL_INTERVAL_MS = 250
WAIT_CLEARED_S = 10
async def solve(self, page: Page, info: ChallengeInfo): async def solve(self, page: Page, info: ChallengeInfo):
print("start solving") log.debug("start solving")
iframe = page.frame(url=__CF_PATTERN__) iframe = page.frame(url=__CF_PATTERN__)
if iframe: if iframe:
await page.wait_for_timeout(randint(500, 1000)) await page.wait_for_timeout(randint(500, 1000))
@@ -212,11 +213,11 @@ class CloudflareSolver(Solver):
finally: finally:
return return
else: else:
deadline = time.time() + 10 deadline = time.time() + self.WAIT_CLEARED_S
while time.time() < deadline: while time.time() < deadline:
if not await page.query_selector(info.detect_locator): if not await page.query_selector(info.detect_locator):
return return
await page.wait_for_timeout(1) await page.wait_for_timeout(self.POLL_INTERVAL_MS)
return return
elif info.stage == Stage.INTERSTITIAL: elif info.stage == Stage.INTERSTITIAL:
if info.success_locator: if info.success_locator:
@@ -227,11 +228,11 @@ class CloudflareSolver(Solver):
else: else:
return return
else: else:
deadline = time.time() + 10 deadline = time.time() + self.WAIT_CLEARED_S
while time.time() < deadline: while time.time() < deadline:
if not await page.query_selector(info.detect_locator): if not await page.query_selector(info.detect_locator):
return return
await page.wait_for_timeout(1) await page.wait_for_timeout(self.POLL_INTERVAL_MS)
return return
else: else:
@@ -257,7 +258,7 @@ class CloudflareSolver(Solver):
@register @register
class ReCaptchaProvider(AntiBotProvider): class CloudflareProvider(AntiBotProvider):
provider = 'cloudflare' provider = 'cloudflare'
_solver_cls = CloudflareSolver _solver_cls = CloudflareSolver
_detector_cls = CloudflareDetector _detector_cls = CloudflareDetector
+3 -4
View File
@@ -11,8 +11,6 @@ from ..registry import register
from ..exceptions import IframeLoadError from ..exceptions import IframeLoadError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler())
JS_CHALLENGE_SUBMIT = """(token) => { JS_CHALLENGE_SUBMIT = """(token) => {
@@ -36,6 +34,8 @@ JS_CHALLENGE_SUBMIT = """(token) => {
class ReCaptchaSolver(Solver): class ReCaptchaSolver(Solver):
page: Page page: Page
# пауза на стадии INTERSTITIAL перед повторным detect
INTERSTITIAL_WAIT_MS = 15_000
def __init__(self, captcha_solver: Captcha, **kwargs) -> None: def __init__(self, captcha_solver: Captcha, **kwargs) -> None:
super().__init__(captcha_solver=captcha_solver, **kwargs) super().__init__(captcha_solver=captcha_solver, **kwargs)
@@ -49,8 +49,7 @@ class ReCaptchaSolver(Solver):
self.page = page self.page = page
log.debug("Начинаю решение recaptcha антибот преграды") log.debug("Начинаю решение recaptcha антибот преграды")
if info.stage == Stage.INTERSTITIAL: if info.stage == Stage.INTERSTITIAL:
# TODO: вытащить await page.wait_for_timeout(self.INTERSTITIAL_WAIT_MS)
await page.wait_for_timeout(15)
return return
elif info.stage == Stage.CHECKBOX: elif info.stage == Stage.CHECKBOX:
log.debug("Нужно тыкнуть чекбокс") log.debug("Нужно тыкнуть чекбокс")
+20 -5
View File
@@ -3,9 +3,10 @@
""" """
import logging import logging
import time import time
import traceback
from dataclasses import dataclass from dataclasses import dataclass
from patchright.async_api import Error as PlaywrightError
from captcha import load_captcha from captcha import load_captcha
from .base import Stage from .base import Stage
@@ -13,8 +14,8 @@ from .registry import select_one
from .exceptions import ProviderNotDetected from .exceptions import ProviderNotDetected
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler()) SOLVE_RETRY_DELAY_MS = 1000
@dataclass @dataclass
@@ -31,6 +32,10 @@ async def pass_challenges(
success_locator: str | None = None, success_locator: str | None = None,
detect_locator: str | None = None, detect_locator: str | None = None,
timeout_ms=120_000) -> ChallengeOutcome: timeout_ms=120_000) -> ChallengeOutcome:
if not antibot_name:
# провайдер не указан — антибот не проходим, сразу к действиям
return ChallengeOutcome(True, reason="antibot not requested")
# установка дедлайна # установка дедлайна
deadline = time.monotonic() + timeout_ms / 1000 deadline = time.monotonic() + timeout_ms / 1000
@@ -49,7 +54,14 @@ async def pass_challenges(
info = None info = None
while time.monotonic() < deadline: while time.monotonic() < deadline:
info = await provider.detect(page) # noqa try:
info = await provider.detect(page) # noqa
except PlaywrightError as e:
# челлендж сам переходит на целевую страницу посреди detect()
# («Execution context was destroyed») — даём навигации закончиться и смотрим заново
log.debug("%s: detect interrupted (%s), retrying", provider.provider, e.message.splitlines()[0])
await page.wait_for_load_state("load")
continue
# 3. проверяем что решение успешно # 3. проверяем что решение успешно
if info.success: # noqa if info.success: # noqa
return ChallengeOutcome(True, reason=(info.meta or {}).get("reason")) return ChallengeOutcome(True, reason=(info.meta or {}).get("reason"))
@@ -57,7 +69,10 @@ async def pass_challenges(
try: try:
await provider.solve(page, info) await provider.solve(page, info)
except Exception as e: except Exception as e:
traceback.print_exc() log.warning("%s: solve failed (%s), retrying", provider.provider, str(e).splitlines()[0] if str(e) else type(e).__name__)
log.debug("solve traceback", exc_info=True)
# пауза, иначе при стабильно падающем solve цикл крутится вхолостую до дедлайна
await page.wait_for_timeout(SOLVE_RETRY_DELAY_MS)
continue continue
else: else:
return ChallengeOutcome( return ChallengeOutcome(
+2 -1
View File
@@ -29,4 +29,5 @@ async def select_one(page: Page, provider_name: str, **kwargs) -> AntiBotProvide
provider.configure(**kwargs) provider.configure(**kwargs)
return provider return provider
else: else:
raise ProviderNotDetected() known = ", ".join(p.provider for p in _detectors)
raise ProviderNotDetected(f"unknown captcha_type {provider_name!r}, expected one of: {known}")
+33 -27
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
import time
from patchright.async_api import Page from patchright.async_api import Page
from antibot.base import click_and_race
from antibot.orchestrator import pass_challenges from antibot.orchestrator import pass_challenges
from captcha import load_captcha from captcha import load_captcha
from api.schemas import ActionRequest from api.schemas import ActionRequest
@@ -10,50 +10,52 @@ from api.schemas import ActionRequest
_actions: dict = {} _actions: dict = {}
class ChallengeFailed(Exception):
pass
def register(fun) -> None: def register(fun) -> None:
_actions[fun.__name__] = fun _actions[fun.__name__] = fun
return fun return fun
async def _query(page: Page, selector: str):
element = await page.query_selector(selector)
if element is None:
raise LookupError(f"selector not found: {selector}")
return element
@register @register
async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None: async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
assert act.value
await page.fill(act.selector, act.value) await page.fill(act.selector, act.value)
@register @register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None: async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
assert act.selector
assert act.value
assert captcha_key
captcha_solver = load_captcha(captcha_key, act.value) captcha_solver = load_captcha(captcha_key, act.value)
await captcha_solver.solve(act.selector, page) await captcha_solver.solve(act.selector, page)
@register @register
async def delay(page: Page, act: ActionRequest, **kwargs) -> str | None: async def delay(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.value
await asyncio.sleep(int(act.value)) await asyncio.sleep(int(act.value))
@register @register
async def mouse_move(page: Page, act: ActionRequest, **kwargs) -> str | None: async def mouse_move(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector element = await _query(page, act.selector)
element = await page.query_selector(act.selector)
await element.hover(scroll="auto") await element.hover(scroll="auto")
@register @register
async def click(page: Page, act: ActionRequest, **kwargs) -> str | None: async def click(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector) element = page.locator(act.selector)
await element.click() await element.click()
@register @register
async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None: async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector
element = page.locator(act.selector) element = page.locator(act.selector)
async with page.expect_event( async with page.expect_event(
@@ -65,19 +67,13 @@ async def submit(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register @register
async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None: async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector element = await _query(page, act.selector)
element = await page.query_selector(act.selector)
return await element.inner_html() return await element.inner_html()
@register @register
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None: async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
assert act.selector await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
assert 1 <= act.timeout <= 7200
try:
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
except Exception as e:
return str(e)
@register @register
@@ -92,6 +88,8 @@ async def pass_challenge(
success_locator=act.selector or success_locator, success_locator=act.selector or success_locator,
detect_locator=detect_locator detect_locator=detect_locator
) )
if not result.cleared:
raise Exception(f"challenge not passed: {result.reason}")
return result.reason return result.reason
@@ -100,7 +98,10 @@ async def execute(
captcha_key: str | None = None, antibot_name: str | None = None, captcha_key: str | None = None, antibot_name: str | None = None,
success_locator: str | None = None, success_locator: str | None = None,
detect_locator: str | None = None) -> list[dict]: detect_locator: str | None = None) -> list[dict]:
"""Выполнение очереди автоматизации""" """Выполнение очереди автоматизации.
Если антибот не пройден — бросает ChallengeFailed, действия не выполняются: на странице-заглушке
они бы упали с невнятной ошибкой селектора."""
actions_result = [] actions_result = []
ctx = { ctx = {
"captcha_key": captcha_key, "captcha_key": captcha_key,
@@ -108,27 +109,32 @@ async def execute(
"success_locator": success_locator, "success_locator": success_locator,
"detect_locator": detect_locator, "detect_locator": detect_locator,
} }
await pass_challenges( outcome = await pass_challenges(
antibot_name, page, antibot_name, page,
captcha=captcha_key, captcha=captcha_key,
success_locator=success_locator, success_locator=success_locator,
detect_locator=detect_locator, detect_locator=detect_locator,
) )
if not outcome.cleared:
stage = outcome.last_stage.value if outcome.last_stage else None
raise ChallengeFailed(f"{antibot_name} not passed: {outcome.reason} (stage: {stage})")
for act in actions: for act in actions:
new_line = { new_line = {
"type": act.type, "type": act.type,
} }
started = time.monotonic()
try: try:
# TODO: добавить время выполнения
result = await _actions[act.type](page, act, **ctx) result = await _actions[act.type](page, act, **ctx)
except Exception as e: except Exception as e:
new_line["ok"] = False new_line["ok"] = False
new_line["result"] = str(e) new_line["result"] = str(e) or type(e).__name__
actions_result.append(new_line)
break
else: else:
new_line["ok"] = True new_line["ok"] = True
if result: if result:
new_line["result"] = result new_line["result"] = result
actions_result.append(new_line) new_line["duration_ms"] = int((time.monotonic() - started) * 1000)
return actions_result if len(actions_result) else [{"status": "pass"}] actions_result.append(new_line)
if not new_line["ok"]:
break
return actions_result
+94 -4
View File
@@ -1,25 +1,70 @@
from typing import Any from typing import Any, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel from pydantic import BaseModel, field_validator, model_validator
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
PROXY_SCHEMES = ("http", "https", "socks5")
# тип действия -> обязательные поля (кроме type)
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"fill": ("selector", "value"),
"captcha": ("selector", "value"),
"delay": ("value",),
"mouse_move": ("selector",),
"click": ("selector",),
"submit": ("selector",),
"inner_html": ("selector",),
"wait_for": ("selector",),
"pass_challenge": (),
}
WAIT_FOR_DEFAULT_TIMEOUT = 30
class ActionRequest(BaseModel): class ActionRequest(BaseModel):
""" """
Действие автоматизации. Характеризуется типом и производится над селектором. Действие автоматизации. Характеризуется типом и производится над селектором.
Набор параметров зависит от типа, этот класс перечисляет все параметры. Набор параметров зависит от типа, этот класс перечисляет все параметры.
Обязательные поля для каждого типа проверяются здесь же — неверное действие
отклоняется с 422 до запуска браузера.
""" """
type: str type: str
selector: str | None = None selector: str | None = None
value: str | None = None value: str | None = None
timeout: int | None = None timeout: int | None = None
@model_validator(mode="after")
def check_fields(self):
if self.type not in _REQUIRED_FIELDS:
raise ValueError(f"unknown action type {self.type!r}, expected one of: {', '.join(_REQUIRED_FIELDS)}")
missing = [f for f in _REQUIRED_FIELDS[self.type] if getattr(self, f) in (None, "")]
if missing:
raise ValueError(f"action {self.type!r} requires: {', '.join(missing)}")
if self.type == "wait_for":
if self.timeout is None:
self.timeout = WAIT_FOR_DEFAULT_TIMEOUT
if not 1 <= self.timeout <= 7200:
raise ValueError("wait_for timeout must be between 1 and 7200 seconds")
elif self.type == "delay":
try:
delay = int(self.value)
except ValueError:
raise ValueError("delay value must be an integer number of seconds") from None
if not 0 <= delay <= 600:
raise ValueError("delay value must be between 0 and 600 seconds")
elif self.type == "captcha" and self.value not in CAPTCHA_TYPES:
raise ValueError(f"captcha value must be one of: {', '.join(CAPTCHA_TYPES)}")
return self
class SolveRequest(BaseModel): class SolveRequest(BaseModel):
""" """
Объект запроса для автоматизации на сайте. Объект запроса для автоматизации на сайте.
""" """
url: str url: str
proxy: str proxy: str | None = None
timeout: int | None = None timeout: int | None = None
user_agent: str | None = None user_agent: str | None = None
cookies: list[dict[str, Any]] | None = None cookies: list[dict[str, Any]] | None = None
@@ -28,7 +73,7 @@ class SolveRequest(BaseModel):
actions: list[ActionRequest] actions: list[ActionRequest]
html: bool = False html: bool = False
# TODO: screenshot: bool = False screenshot: bool = False
captcha: str | None = None captcha: str | None = None
captcha_detect_locator: str | None = None captcha_detect_locator: str | None = None
@@ -43,3 +88,48 @@ class SolveRequest(BaseModel):
retry_delay: float = 1.0 retry_delay: float = 1.0
wait: int = 0 wait: int = 0
google_search: bool = False google_search: bool = False
@field_validator("proxy")
@classmethod
def check_proxy(cls, proxy: str | None) -> str | None:
if not proxy:
return None
try:
parts = urlsplit(proxy)
port = parts.port
except ValueError as e:
raise ValueError(f"invalid proxy url: {e}") from None
if parts.scheme not in PROXY_SCHEMES:
raise ValueError(f"proxy scheme must be one of: {', '.join(PROXY_SCHEMES)}")
if not parts.hostname or not port:
raise ValueError("proxy must be in form scheme://[user:pass@]host:port")
return proxy
@model_validator(mode="after")
def check_captcha_key(self):
if not self.captcha and any(a.type == "captcha" for a in self.actions):
raise ValueError("action 'captcha' requires request field 'captcha' (2captcha key)")
return self
class ActionResult(BaseModel):
type: str
ok: bool
result: str | None = None
duration_ms: int
class SolveResponse(BaseModel):
"""
Ответ /v1/solve. status — "ok" (антибот пройден, все действия выполнены) или "err".
code — машиночитаемая причина ошибки, error — текст для человека.
"""
status: Literal["ok", "err"]
code: Literal["no_slots", "request_failed", "challenge_failed", "action_failed"] | None = None
error: str | None = None
user_agent: str | None = None
cookies: list[dict[str, Any]] | None = None
actions: list[ActionResult] = []
html: str | None = None
screenshot: str | None = None
duration_ms: int | None = None
-39
View File
@@ -1,48 +1,9 @@
import asyncio import asyncio
import json
import hashlib
import time
from pathlib import Path
from patchright.async_api import Page from patchright.async_api import Page
from twocaptcha import TwoCaptcha from twocaptcha import TwoCaptcha
def cached_persistent(file_name=None):
def key(*args, **kwargs):
return hashlib.sha256(f'{str(args)}-{str(kwargs)}'.encode()).hexdigest()
def wrapper(fun):
def clear_cache():
cache_file.write_text(json.dumps({}))
cache_file = Path(file_name)
if not cache_file.exists():
cache_file.write_text(json.dumps({}))
async def wrapped(*args, **kwargs):
data = json.loads(cache_file.read_text())
_key = key(fun.__name__, *args, **kwargs)
if cached_value := data.get(_key):
cached_value, cache_time = cached_value
if time.time() - cache_time > 300:
cached_value = None
value = cached_value or await fun(*args, **kwargs)
data[_key] = [value, time.time()]
cache_file.write_text(json.dumps(data))
return value
setattr(wrapped, '_clear_cache', clear_cache)
return wrapped
return wrapper
class Captcha: class Captcha:
ctype = "captcha" ctype = "captcha"
-2
View File
@@ -16,8 +16,6 @@ import logging
from .base import Captcha, Page from .base import Captcha, Page
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverHCaptcha(Captcha): class SolverHCaptcha(Captcha):
-2
View File
@@ -7,8 +7,6 @@ from urllib.parse import urlparse, parse_qs
from .base import Captcha, Page from .base import Captcha, Page
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverReCaptcha(Captcha): class SolverReCaptcha(Captcha):
-2
View File
@@ -16,8 +16,6 @@ import logging
from .base import Captcha, Page from .base import Captcha, Page
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
class SolverTurnstile(Captcha): class SolverTurnstile(Captcha):
+192 -103
View File
@@ -1,6 +1,9 @@
import asyncio
import base64
import json import json
import logging import logging
import traceback import os
import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@@ -8,19 +11,29 @@ from urllib.parse import urlsplit
from fastapi import Cookie from fastapi import Cookie
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import Request
from fastapi import Response from fastapi import Response
from fastapi.responses import JSONResponse
from patchright.async_api import Page, ProxySettings from patchright.async_api import Page, ProxySettings
from api import actions logging.basicConfig(
from api.geoip import resolve_geo level=os.getenv("LOG_LEVEL", "INFO").upper(),
from api.schemas import SolveRequest format="%(asctime)s %(levelname)s %(name)s: %(message)s",
from engine import AsyncStealthySession )
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler()) # сколько Chrome запускается параллельно; подбирать ≈ числу ядер (см. docs/Deployment.md)
BROWSER_SLOTS = 10 BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "10"))
RETRY_AFTER = os.getenv("NO_SLOTS_RETRY_AFTER", "5")
_slots = asyncio.Semaphore(BROWSER_SLOTS)
USER_DATA_ROOT = (Path(__file__).parent / "../extra/user_data_dir").resolve()
SESSION_SETTINGS = "robotex_session.json"
def parse_proxy(proxy_url: str) -> ProxySettings: def parse_proxy(proxy_url: str) -> ProxySettings:
@@ -34,23 +47,97 @@ def parse_proxy(proxy_url: str) -> ProxySettings:
return settings return settings
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_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)
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)
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:
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
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 {}
def save_session_settings(user_data_dir: Path, settings: dict) -> None:
(user_data_dir / SESSION_SETTINGS).write_text(json.dumps(settings, ensure_ascii=False))
def make_page_action(payload: SolveRequest): def make_page_action(payload: SolveRequest):
"""Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий """Замыкание для AsyncStealthySession.fetch(page_action=...): выполняет очередь действий
(включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже (включая прохождение капчи/антибота через api.actions.execute) и достаёт из уже
навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что навигированной страницы всё, что нужно для ответа API. Возврат этой функции — то, что
вернёт fetch() (см. engine/stealthy.py).""" вернёт fetch() (см. engine/stealthy.py).
Непройденный антибот не бросается наружу, а возвращается в challenge_error — чтобы в ответ
всё равно попали cookies, html и скриншот страницы-заглушки."""
async def page_action(page: Page) -> dict: async def page_action(page: Page) -> dict:
actions_result = await actions.execute( challenge_error = None
page, payload.actions, payload.captcha, try:
antibot_name=payload.captcha_type, actions_result = await actions.execute(
success_locator=payload.captcha_success_locator, page, payload.actions, payload.captcha,
detect_locator=payload.captcha_detect_locator, 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 { return {
"challenge_error": challenge_error,
"actions": actions_result, "actions": actions_result,
"cookies": await page.context.cookies(), "cookies": await page.context.cookies(),
"html": await page.inner_html("html"), "html": await page.inner_html("html"),
"screenshot": screenshot,
"user_agent": await page.evaluate("() => navigator.userAgent"), "user_agent": await page.evaluate("() => navigator.userAgent"),
} }
@@ -61,103 +148,105 @@ app = FastAPI()
@app.get("/") @app.get("/")
async def root(): @app.get("/health")
return {"message": "Hello World"} async def health():
return {"status": "ok", "browser_slots": BROWSER_SLOTS, "free_slots": _slots._value} # noqa
@app.post("/v1/solve") @app.post("/v1/solve", response_model=SolveResponse, response_model_exclude_none=True)
async def solve( async def solve(
request: Request,
payload: SolveRequest, payload: SolveRequest,
response: Response, response: Response,
session_id: Annotated[str | None, Cookie()] = None session_id: Annotated[str | None, Cookie()] = None
): ):
# TODO: добавить поддержку api ключей # TODO: добавить поддержку api ключей (#4)
api_key = request.headers.get('X-API-Key') started = time.monotonic()
logger.info("Incoming payload: [%s]\n%s", session_id, payload.model_dump_json(indent=2, ensure_ascii=True)) if _slots.locked():
global BROWSER_SLOTS logger.warning("no free browser slots (%d), rejecting %s", BROWSER_SLOTS, payload.url)
return JSONResponse(
if BROWSER_SLOTS <= 0: status_code=503,
return { headers={"Retry-After": RETRY_AFTER},
"ok": False, content={"status": "err", "code": "no_slots", "error": "No slots available"},
"error": "No slots available",
}
BROWSER_SLOTS -= 1
try:
if not session_id:
session_id = str(uuid.uuid4())
response.set_cookie("session_id", session_id)
logger.info("Setup new session with session_id: %s", session_id)
proxy = parse_proxy(payload.proxy)
# профиль (cookies/local storage/fingerprint.json) хранится на диске по
# session_id — если он уже приходил в куках, ниже подгружаем существующий
# fingerprint из этой папки; если нет (новый session_id) — генерируем новый
# и сохраняем туда же (см. api/fingerprint.py:load_or_create_fingerprint)
user_data_dir = Path(__file__).parent / f"../extra/user_data_dir/{session_id}"
if not user_data_dir.exists():
user_data_dir.mkdir(parents=True)
# geo — до создания fingerprint'а, чтобы производная от прокси locale
# (см. api/geoip.py) попала в сам fingerprint, а не подменяла его
# задним числом
geo = await resolve_geo(proxy)
locale = geo.get("locale") if geo else None
fp_kwargs = {}
if payload.user_agent:
fp_kwargs["user_agent"] = payload.user_agent
# TODO: если есть session_id, то user_agent нужно взять из настроек пользователя
if locale:
fp_kwargs["locale"] = locale
nav_timeout_ms = payload.timeout * 1000 if payload.timeout else 60_000
blocked_domains = set(payload.blocked_domains) if payload.blocked_domains else None
kwargs = dict(
headless=False,
proxy=proxy,
user_data_dir=str(user_data_dir),
real_chrome=True,
locale=locale,
timezone_id=geo.get("timezone_id") if geo else None,
cookies=payload.cookies,
disable_resources=payload.disable_resources,
blocked_domains=blocked_domains,
timeout=nav_timeout_ms,
retries=max(1, payload.retries),
retry_delay=payload.retry_delay,
wait=payload.wait,
google_search=payload.google_search
) )
async with AsyncStealthySession(**kwargs) as engine: async with _slots:
result = await engine.fetch(payload.url, page_action=make_page_action(payload)) session_id, user_data_dir = session_dir(session_id)
except Exception as e: response.set_cookie("session_id", session_id)
traceback.print_exc() logger.info("Incoming request [%s]: %s", session_id, payload.url)
result_payload = { logger.debug("Incoming payload [%s]:\n%s", session_id, safe_request_dump(payload))
"error": str(e),
"status": False
}
else:
status = not "error" in result["actions"][-1]
error = ["Something error", None][status]
result_payload = { try:
"status": ["err", "ok"][status], user_data_dir.mkdir(parents=True, exist_ok=True)
"user_agent": result["user_agent"],
"cookies": result["cookies"],
"actions": result["actions"],
}
if payload.html:
result_payload["html"] = result["html"]
if error:
result_payload["error"] = error
finally:
BROWSER_SLOTS += 1
logger.info("Outgoing payload:\n%s", json.dumps(result_payload, indent=2, sort_keys=True, ensure_ascii=True)) # 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 return result_payload
+138
View File
@@ -0,0 +1,138 @@
"""Нагрузочный тест /v1/solve: N параллельных запросов на каждом уровне + пиковое потребление
контейнера по `docker stats`.
docker compose --profile loadtest up -d
python tests/loadtest.py --levels 1 5 10 15
Помимо status ответа, неуспех раскладывается по причинам: нет слотов, застряли на Cloudflare,
упало действие, исключение до действий.
"""
import argparse
import asyncio
import json
import statistics
import subprocess
import threading
import time
import httpx
CF_MARKERS = ("challenges.cloudflare.com", "Just a moment", "cf-turnstile")
def build_payload(args) -> dict:
return {
"url": args.url,
"proxy": args.proxy,
"timeout": 120,
"html": True,
"captcha_type": args.captcha_type,
"actions": [{"type": "wait_for", "selector": args.selector, "timeout": 60}],
}
def classify(resp: httpx.Response | Exception) -> str:
if isinstance(resp, Exception):
return f"exc:{type(resp).__name__}"
data = resp.json()
code = data.get("code")
if code == "no_slots":
return "no_slots"
if code == "challenge_failed":
return "cf_blocked"
if code == "action_failed":
html = data.get("html") or ""
return "cf_blocked" if any(m in html for m in CF_MARKERS) else "action_failed"
if data.get("status") != "ok":
return f"error:{str(data.get('error'))[:60]}"
return "ok"
class StatsSampler(threading.Thread):
"""Раз в ~2с снимает docker stats контейнера, копит пики CPU/памяти и число процессов."""
def __init__(self, container: str):
super().__init__(daemon=True)
self.container = container
self.stop_event = threading.Event()
self.cpu = 0.0
self.mem_mb = 0.0
self.pids = 0
@staticmethod
def _to_mb(value: str) -> float:
units = {"KiB": 1 / 1024, "MiB": 1, "GiB": 1024, "kB": 1 / 1000, "MB": 1, "GB": 1000, "B": 1 / 2**20}
for unit, k in units.items():
if value.endswith(unit):
return float(value[: -len(unit)]) * k
return 0.0
def run(self):
while not self.stop_event.is_set():
out = subprocess.run(
["docker", "stats", "--no-stream", "--format", "{{json .}}", self.container],
capture_output=True, text=True,
).stdout.strip()
if out:
s = json.loads(out)
self.cpu = max(self.cpu, float(s["CPUPerc"].rstrip("%")))
self.mem_mb = max(self.mem_mb, self._to_mb(s["MemUsage"].split("/")[0].strip()))
self.pids = max(self.pids, int(s["PIDs"]))
self.stop_event.wait(1)
async def one(client: httpx.AsyncClient, api: str, payload: dict) -> tuple[str, float]:
t0 = time.monotonic()
try:
resp = await client.post(f"{api}/v1/solve", json=payload)
except Exception as e:
resp = e
return classify(resp), time.monotonic() - t0
async def run_level(n: int, args) -> None:
sampler = StatsSampler(args.container)
sampler.start()
t0 = time.monotonic()
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)))
wall = time.monotonic() - t0
sampler.stop_event.set()
sampler.join()
outcomes: dict[str, int] = {}
for outcome, _ in results:
outcomes[outcome] = outcomes.get(outcome, 0) + 1
ok_times = [d for o, d in results if o == "ok"]
median = f"{statistics.median(ok_times):.1f}" if ok_times else "-"
print(
f"| {n:>3} | {outcomes.get('ok', 0):>3}/{n:<3} | {median:>8} | {wall:>7.1f} "
f"| {sampler.cpu:>7.0f} | {sampler.mem_mb / 1024:>7.2f} | {sampler.pids:>5} | "
+ ", ".join(f"{k}={v}" for k, v in sorted(outcomes.items()) if k != "ok"),
flush=True,
)
async def main():
p = argparse.ArgumentParser()
p.add_argument("--api", default="http://127.0.0.1:8000")
p.add_argument("--url", default="https://freelancehunt.com/projects")
p.add_argument("--selector", default='a[href*="/project/"]')
p.add_argument("--proxy", default="http://proxy:8080")
p.add_argument("--captcha-type", default="cloudflare")
p.add_argument("--container", default="webroboapi-api-1")
p.add_argument("--levels", type=int, nargs="+", default=[1, 5, 10])
p.add_argument("--pause", type=float, default=10, help="пауза между уровнями, с")
p.add_argument("--http-timeout", type=float, default=300)
args = p.parse_args()
print("| par | ok | med ok,s | wall,s | peak CPU% | peak GiB | PIDs | прочее")
print("|-----|---------|----------|---------|-----------|----------|------|-------")
for i, n in enumerate(args.levels):
if i:
await asyncio.sleep(args.pause)
await run_level(n, args)
if __name__ == "__main__":
asyncio.run(main())