Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a399f7f8cc | ||
|
|
437ea27be7 | ||
|
|
f58306cbb4 | ||
|
|
b8499e2ce6 | ||
|
|
a319b81d8b | ||
|
|
df108dae3f | ||
|
|
c54500b178 | ||
|
|
08a7501d31 | ||
|
|
7e148f0393 |
No files matched your search
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
wheels/
|
wheels/
|
||||||
docs/*.bkp
|
docs/*.bkp
|
||||||
docs/*.dtmp
|
docs/*.dtmp
|
||||||
|
build/
|
||||||
@@ -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
|
||||||
%:
|
%:
|
||||||
@:
|
@:
|
||||||
@@ -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).
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Список задач
|
|
||||||
|
|
||||||
## Срочные и Важные
|
|
||||||
|
|
||||||
-[ ] не удается пройти cf_turnstile
|
|
||||||
|
|
||||||
## Не срочные и Важные
|
|
||||||
|
|
||||||
-[ ] добавить пакет обертку для python
|
|
||||||
|
|
||||||
## Срочные и Не важные
|
|
||||||
|
|
||||||
## Не срочные и Не срочные
|
|
||||||
|
|
||||||
-[ ]
|
|
||||||
@@ -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
@@ -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
@@ -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#нагрузка).
|
||||||
@@ -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
@@ -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 - появляется, для команд возвращающих результат
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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("Нужно тыкнуть чекбокс")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in new issue
Block a user