Compare commits
7
Commits
4e930caccb
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a319b81d8b | ||
|
|
df108dae3f | ||
|
|
c54500b178 | ||
|
|
08a7501d31 | ||
|
|
7e148f0393 | ||
|
|
ae5cefc6df | ||
|
|
08a6b52b9d |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.idea
|
||||
.venv
|
||||
wheels
|
||||
extra
|
||||
docs
|
||||
tests
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
@@ -1,6 +1,7 @@
|
||||
extra/
|
||||
.venv/
|
||||
.idea/
|
||||
__pycache__/
|
||||
wheels/
|
||||
docs/*.bkp
|
||||
docs/*.dtmp
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Xvfb — браузер запускается в headful-режиме (headless=False), нужен виртуальный дисплей
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini xvfb xauth fonts-liberation fonts-noto-color-emoji ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# real_chrome=True → channel "chrome": ставим Google Chrome stable вместе с системными зависимостями
|
||||
RUN patchright install --with-deps chrome \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --uid 1000 app \
|
||||
&& mkdir -p /app/extra \
|
||||
&& chown -R app:app /app
|
||||
|
||||
COPY --chown=app:app src/ ./src/
|
||||
|
||||
USER app
|
||||
WORKDIR /app/src
|
||||
|
||||
# профили браузера (extra/user_data_dir) и geoip-кэш — в volume
|
||||
VOLUME ["/app/extra"]
|
||||
EXPOSE 8000
|
||||
|
||||
# tini как PID 1: пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb (иначе — зомби)
|
||||
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
|
||||
CMD ["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", \
|
||||
"uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -13,15 +13,15 @@ Early MVP (v0), under active development. Single browser engine, no queue, no bi
|
||||
Client → Caddy (TLS) → FastAPI → API-key auth → semaphore acquire
|
||||
│
|
||||
▼
|
||||
Camoufox browser context
|
||||
Chrome (Patchright) context
|
||||
(run actions, collect result)
|
||||
│
|
||||
▼
|
||||
release semaphore → response
|
||||
```
|
||||
|
||||
- **Engine**: [Camoufox](https://camoufox.com/) (stealth Firefox, driven via Playwright). Chosen for v1 because
|
||||
Firefox-based fingerprinting is less commoditized against anti-bot vendors than patched Chromium.
|
||||
- **Engine**: real Google Chrome (`channel="chrome"`) driven via [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python)
|
||||
(undetected Playwright fork), headful under Xvfb. Firefox is not supported yet.
|
||||
- **API**: FastAPI, single versioned endpoint (`POST /v1/solve`).
|
||||
- **Concurrency**: bounded by an `asyncio.Semaphore` over browser contexts — no external job queue in v1. Simpler to
|
||||
run, hard ceiling on throughput per instance (see Roadmap for scaling out).
|
||||
@@ -29,53 +29,48 @@ Client → Caddy (TLS) → FastAPI → API-key auth → semaphore acquire
|
||||
- **Logging**: every request's outcome (success/failure/duration) is persisted — this becomes the success-rate data used
|
||||
to evaluate the service and talk to clients about reliability.
|
||||
- **Proxies**: not managed by the service in v1 — the client supplies their own proxy per request if they need one.
|
||||
- **Deployment**: single Docker container behind a Caddy reverse proxy.
|
||||
- **Deployment**: single Docker container behind a Caddy reverse proxy — see [Docker](#docker) and
|
||||
[docs/Deployment.md](docs/Deployment.md).
|
||||
- **Canvas fingerprint** по дизайну генерируется каждую сессию новый.
|
||||
|
||||
### Planned project layout
|
||||
### Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.py # FastAPI app + router wiring
|
||||
config.py # settings (env vars)
|
||||
main.py # FastAPI app, POST /v1/solve
|
||||
api/
|
||||
solve.py # POST /v1/solve
|
||||
deps.py # API-key auth dependency
|
||||
engine/
|
||||
browser.py # Camoufox context pool + semaphore
|
||||
actions.py # fill / click / wait_for execution
|
||||
schemas.py # SolveRequest / SolveResponse / Action models
|
||||
db/
|
||||
models.py # api_keys, request_logs
|
||||
session.py
|
||||
schemas.py # SolveRequest / Action models
|
||||
geoip.py # proxy → locale / timezone
|
||||
engine/
|
||||
stealthy.py # AsyncStealthySession (Patchright + Chrome)
|
||||
session.py # browser / context launch options
|
||||
page_pool.py # page pool
|
||||
antibot/ # anti-bot detection & bypass orchestrator (Cloudflare, reCAPTCHA)
|
||||
captcha/ # captcha solvers (Turnstile, reCAPTCHA, hCaptcha)
|
||||
tests/
|
||||
wheels/ # whl библиотеки для оффлайн установки под Ubuntu Noble
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
requirements.txt
|
||||
env.example
|
||||
```
|
||||
|
||||
## API (draft)
|
||||
## API
|
||||
|
||||
```
|
||||
POST /v1/solve
|
||||
X-API-Key: <key>
|
||||
|
||||
{
|
||||
"captcha": "token",
|
||||
"url": "https://example.com/login",
|
||||
"url": "https://freelancehunt.com/projects",
|
||||
"proxy": "http://user:pass@host:port",
|
||||
"captcha_type": "cloudflare",
|
||||
"actions": [
|
||||
{"type": "fill", "selector": "#email", "value": "user@example.com"},
|
||||
{"type": "fill", "selector": "#password", "value": "..."},
|
||||
{"type": "mouse_move", "selector": "#submit"},
|
||||
{"type": "click", "selector": "#submit"},
|
||||
{"type": "wait_for", "selector": ".dashboard", "timeout": 30}
|
||||
{"type": "wait_for", "selector": "a[href*=\"/project/\"]", "timeout": 60}
|
||||
],
|
||||
"proxy": "socks5://user:pass@host:port",
|
||||
|
||||
"screen": "1280x920",
|
||||
"user_agent": "Firefox",
|
||||
"timeout": 120
|
||||
"html": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -83,46 +78,15 @@ Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"context": "000000",
|
||||
"error": "action_error",
|
||||
"html": "<html>...</html>",
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session",
|
||||
"value": "..."
|
||||
}
|
||||
],
|
||||
"actions_result": [
|
||||
{
|
||||
"type": "fill",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"type": "wait_for",
|
||||
"ok": false,
|
||||
"error": "Element not located"
|
||||
}
|
||||
]
|
||||
"status": "ok",
|
||||
"user_agent": "Mozilla/5.0 (...) Chrome/155.0.0.0 Safari/537.36",
|
||||
"cookies": [{"name": "cf_clearance", "value": "..."}],
|
||||
"actions": [{"type": "wait_for", "ok": true}],
|
||||
"html": "<head>...</head><body>...</body>"
|
||||
}
|
||||
```
|
||||
|
||||
On failure, `status` is `"error"` and `error` carries a machine-readable reason (`selector_not_found`, `timeout`,
|
||||
`navigation_failed`, ...).
|
||||
|
||||
Fill команда включает 3 действия
|
||||
|
||||
1. `{"type": "mouse_move", "selector": "#email"},` - навести мышку на элемент
|
||||
2. `{"type": "click"},` - клик в текущие координаты
|
||||
3. `{"type": "typing", "value": "user@example.com"},` - набор текста
|
||||
Full reference — fields, action types, error formats, sessions: [docs/API.md](docs/API.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -131,7 +95,7 @@ Deliberately out of scope for v1:
|
||||
- Job queue (Redis/RabbitMQ) + multiple worker VPS for horizontal scaling
|
||||
- Real billing/usage plans beyond a flat API-key check
|
||||
- Broader action vocabulary beyond fill/click/wait_for
|
||||
- Second engine (Patchright/Chromium) for targets where the Firefox fingerprint doesn't fit
|
||||
- Second engine (Firefox, e.g. Camoufox) for targets where the Chrome fingerprint doesn't fit
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -139,5 +103,15 @@ Deliberately out of scope for v1:
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
make sync-offline
|
||||
uvicorn app.main:app --reload
|
||||
cd src && uvicorn main:app --reload
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
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
|
||||
`extra` named volume, so sessions survive container restarts. Details (Xvfb, tini, `/dev/shm`) —
|
||||
[docs/Deployment.md](docs/Deployment.md).
|
||||
@@ -1,15 +0,0 @@
|
||||
# Список задач
|
||||
|
||||
## Срочные и Важные
|
||||
|
||||
-[ ] не удается пройти cf_turnstile
|
||||
|
||||
## Не срочные и Важные
|
||||
|
||||
-[ ] добавить пакет обертку для python
|
||||
|
||||
## Срочные и Не важные
|
||||
|
||||
## Не срочные и Не срочные
|
||||
|
||||
-[ ]
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
image: robotex-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- extra:/app/extra
|
||||
# Chrome активно использует /dev/shm, дефолтных 64MB не хватает
|
||||
shm_size: "2gb"
|
||||
# Потолок ресурсов: при переборе OOM-killer прибьёт процесс Chrome внутри контейнера,
|
||||
# а не соседние сервисы на хосте. Подбирать под сервер вместе с BROWSER_SLOTS.
|
||||
cpus: ${API_CPUS:-4}
|
||||
mem_limit: ${API_MEM_LIMIT:-8g}
|
||||
memswap_limit: ${API_MEM_LIMIT:-8g}
|
||||
|
||||
# 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:
|
||||
extra:
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# API
|
||||
|
||||
Один эндпоинт: `POST /v1/solve`. Открывает страницу в Chrome через прокси клиента, при необходимости проходит
|
||||
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет список действий и возвращает cookies, результаты действий и,
|
||||
по запросу, HTML.
|
||||
|
||||
## Запрос
|
||||
|
||||
```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 | — | **обязательно**. `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 страницы после выполнения действий |
|
||||
| `timeout` | int, с | `60` | таймаут навигации |
|
||||
| `user_agent` | string | `null` | свой User-Agent; без него берётся 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 |
|
||||
|
||||
### Действия
|
||||
|
||||
Выполняются по порядку после прохождения антибота. На первой ошибке выполнение останавливается.
|
||||
|
||||
| `type` | Поля | Что делает |
|
||||
|---|---|---|
|
||||
| `wait_for` | `selector`, `timeout` (1–7200 с) | ждёт появления элемента; при таймауте — ошибка действия |
|
||||
| `click` | `selector` | клик по элементу |
|
||||
| `submit` | `selector` | клик и ожидание навигации |
|
||||
| `fill` | `selector`, `value` | вставка значения в поле |
|
||||
| `mouse_move` | `selector` | наведение курсора на элемент |
|
||||
| `delay` | `value` (секунды строкой) | пауза |
|
||||
| `inner_html` | `selector` | возвращает HTML элемента в `result` |
|
||||
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` |
|
||||
| `pass_challenge` | `selector` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия |
|
||||
|
||||
## Ответ
|
||||
|
||||
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}
|
||||
],
|
||||
"html": "<head>...</head><body>...</body>"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `status` | `"ok"` — все действия выполнены, `"err"` — одно из действий упало (выполнение на нём остановлено) |
|
||||
| `user_agent` | UA браузера, с которым проходили антибот (нужен клиенту вместе с `cf_clearance`) |
|
||||
| `cookies` | все cookies контекста |
|
||||
| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки) |
|
||||
| `html` | только при `"html": true` |
|
||||
| `error` | при `status: "err"`: `<type>: <текст ошибки>` упавшего действия |
|
||||
|
||||
Ошибка с упавшим действием:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "err",
|
||||
"actions": [{"type": "wait_for", "ok": false, "result": "Page.wait_for_selector: Timeout 3000ms exceeded. ..."}],
|
||||
"error": "wait_for: Page.wait_for_selector: Timeout 3000ms exceeded. ..."
|
||||
}
|
||||
```
|
||||
|
||||
Ошибка до выполнения действий (навигация, прокси, неизвестный `captcha_type`):
|
||||
|
||||
```json
|
||||
{"status": false, "error": "unknown captcha_type 'datadome', expected one of: recaptcha, cloudflare"}
|
||||
```
|
||||
|
||||
Нет свободных браузеров (лимит `BROWSER_SLOTS`):
|
||||
|
||||
```json
|
||||
{"ok": false, "error": "No slots available"}
|
||||
```
|
||||
|
||||
> Форматы ошибок пока не унифицированы (тех. долг): `status` бывает `"ok"`/`"err"`/`false`, а при занятых слотах
|
||||
> поля `status` нет вовсе. Успех — строго `status == "ok"`.
|
||||
|
||||
## Сессии
|
||||
|
||||
Ответ ставит cookie `session_id`. Если передать её в следующем запросе, браузер откроется с тем же профилем
|
||||
(`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare
|
||||
обычно не нужно. Без cookie каждый запрос получает новый профиль.
|
||||
|
||||
Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля.
|
||||
|
||||
## Производительность
|
||||
|
||||
Каждый запрос запускает отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare.
|
||||
Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка).
|
||||
@@ -57,7 +57,7 @@ src/antibot/
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from playwright.async_api import Page
|
||||
from patchright.async_api import Page
|
||||
|
||||
class Stage(str, Enum):
|
||||
INTERSTITIAL = "interstitial"
|
||||
@@ -188,7 +188,7 @@ async def solve_interactive(page, info, captcha_key: str):
|
||||
|
||||
1. **Траектория мыши** — не прыжок в точку, а несколько промежуточных точек по кривой Безье от текущей позиции курсора до цели, с нелинейным (ease-in-out) распределением скорости и небольшим шумом (±1-2px) на каждом шаге. `page.mouse.move(x, y, steps=N)` даёт линейную интерполяцию — этого мало, нужно звать `mouse.move` несколько раз вручную по расчётным точкам.
|
||||
2. **Ускорение/замедление клика** — задержка `mouse.down()` -> `mouse.up()` рандомная (100-250мс), плюс случайный сдвиг координаты клика внутри bounding box (не в центр, как это делает и сам Scrapling — `randint(26,28)`).
|
||||
3. **Общая стелс-часть** (не про капчу, но без неё стадия A вообще не пройдёт): подмена `navigator.webdriver`, canvas noise, консистентные timezone/locale/UA — то, что в вашем проекте уже частично закрыто Camoufox-фингерпринтом в [main.py](../src/main.py) (`generate_context_fingerprint`). Это нужно сохранить как есть — не переизобретать, Camoufox с этим справляется хорошо, вопрос отказа от него у Scrapling был про производительность/стабильность, а не про качество стелса.
|
||||
3. **Общая стелс-часть** (не про капчу, но без неё стадия A вообще не пройдёт): подмена `navigator.webdriver`, canvas noise, консистентные timezone/locale/UA — сейчас закрывается связкой Patchright + настоящий Google Chrome (`real_chrome=True`) в [engine/stealthy.py](../src/engine/stealthy.py), timezone/locale берутся из геолокации прокси ([api/geoip.py](../src/api/geoip.py)). Camoufox (Firefox) пока остаётся в зависимостях, но как движок не используется — проблемы были скорее с генератором отпечатков, чем с самим Camoufox.
|
||||
|
||||
## 6. Оркестратор (`orchestrator.py`)
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Развёртывание в Docker
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
API доступно на `http://localhost:8000` (`POST /v1/solve`).
|
||||
|
||||
Без compose:
|
||||
|
||||
```bash
|
||||
docker build -t robotex-api .
|
||||
docker run -d --name robotex -p 8000:8000 --shm-size=2g -v robotex-extra:/app/extra robotex-api
|
||||
```
|
||||
|
||||
## Что внутри образа
|
||||
|
||||
| Компонент | Зачем |
|
||||
|---|---|
|
||||
| `python:3.12-slim-bookworm` | базовый образ, Python той же версии, что и в `.venv` |
|
||||
| `requirements.txt` | ставится из PyPI (папка `wheels/` в образ не попадает — она под Ubuntu Noble и неполная) |
|
||||
| Google Chrome stable (`patchright install --with-deps chrome`) | сессия запускается с `real_chrome=True` → channel `chrome` |
|
||||
| Xvfb (`xvfb-run`) | браузер работает в headful-режиме (`headless=False`), нужен виртуальный дисплей 1920x1080x24 |
|
||||
| `tini` (ENTRYPOINT, PID 1) | пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb — без него копятся зомби-процессы |
|
||||
| пользователь `app` (uid 1000) | Chrome не запускается от root без `--no-sandbox` |
|
||||
|
||||
Рабочая директория — `/app/src`, uvicorn запускается как `uvicorn main:app` (импорты в проекте вида `from api import ...`).
|
||||
|
||||
## Данные и volume
|
||||
|
||||
`/app/extra` вынесен в volume `extra`:
|
||||
|
||||
- `extra/user_data_dir/<session_id>` — профили браузера (cookies, local storage, fingerprint) по `session_id`;
|
||||
- `extra/geoip_cache.json` — кэш геолокации прокси.
|
||||
|
||||
Профили со временем накапливаются — чистить вручную:
|
||||
|
||||
```bash
|
||||
docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
|
||||
```
|
||||
|
||||
## Настройки compose
|
||||
|
||||
- `shm_size: 2gb` — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»).
|
||||
- `API_PORT` — порт на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`.
|
||||
- `API_CPUS` / `API_MEM_LIMIT` — потолок ресурсов контейнера (по умолчанию 4 CPU / 8 ГБ, swap выключен).
|
||||
При переборе памяти OOM-killer убивает процесс Chrome внутри контейнера, хост не страдает.
|
||||
- `restart: unless-stopped`.
|
||||
- `.env` подключается, если существует (шаблон — `env.example`).
|
||||
|
||||
## Нагрузка
|
||||
|
||||
Замер: `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 = 10` зашит в `src/main.py` и рассчитан
|
||||
на ~10–12 ядер.
|
||||
- Сверх `BROWSER_SLOTS` запросы сразу получают `No slots available`, не ставятся в очередь.
|
||||
|
||||
Повторить замер:
|
||||
|
||||
```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
|
||||
|
||||
В проде контейнер ставится за Caddy (TLS). Пример `Caddyfile`:
|
||||
|
||||
```
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
```
|
||||
|
||||
Таймауты прокси должны быть не меньше `timeout` запроса (по умолчанию 120 с).
|
||||
+4
-55
@@ -2,59 +2,8 @@
|
||||
|
||||
Сервис позволяет отработать на странице JS и продолжить парсинг. Полезно для ботов, которым нужно авторизоваться на сайте или пройти капчу, там где требуется рендеринг JS.
|
||||
|
||||
### Запрос и ответ
|
||||
Клиент присылает URL, свой прокси и список действий. Сервис открывает страницу в настоящем Chrome, проходит
|
||||
антибот (Cloudflare Turnstile, reCAPTCHA), выполняет действия и возвращает cookies (включая `cf_clearance`),
|
||||
User-Agent и, по запросу, HTML. С этими cookies и UA клиент продолжает парсинг своим HTTP-клиентом.
|
||||
|
||||
**Параметры запроса**:
|
||||
|
||||
- URL - обязательно нужно указать с каким сайтом будем работать
|
||||
- proxy - обязательно нужно задать прокси, система не станет палить свою инфраструктуру
|
||||
- time_zone - временная зона
|
||||
- locale - языковая настройка
|
||||
- user_agent - заголовок браузера Firefox, если не передать будет использован свой
|
||||
- screen - размер окна, по умолчанию: 1280х920
|
||||
- timeout - общий таймаут выполнения запроса, по умолчанию: 120
|
||||
- captcha - токен сервиса распознавания, если требуется решить капчу на странице
|
||||
- actions - перечисление списка действий, которые на странице нужно выполнить
|
||||
|
||||
Настройка actions:
|
||||
|
||||
- type - тип запроса из перечисления
|
||||
- selector - css селектор
|
||||
- value - значение для вставки
|
||||
- timeout - время выполнения или ожидания
|
||||
|
||||
Доступные типы actions:
|
||||
|
||||
- wait_for - ожидание появления элемента
|
||||
- selector
|
||||
- timeout
|
||||
- mouse_move - перемещение курсора
|
||||
- selector
|
||||
- click - клик
|
||||
- selector - если не передан, то клик в текущей позиции
|
||||
- fill - заполняет поля
|
||||
- selector
|
||||
- value - значение будет впечатано
|
||||
|
||||
Будет добавлено:
|
||||
|
||||
1. `{"type": "typing", "value": "user@example.com"},` - набор текста
|
||||
2. `{"type": "innerhtml", "selector": "#email"},` - получить контент
|
||||
3. `{"type": "script", "source": "console.log(\"hello world\")"},` - выполнить скрипт
|
||||
|
||||
**Ответ**:
|
||||
|
||||
- URL - итоговый урл из адресной строки на момент окончания работы
|
||||
- user_agent - заголовок браузера
|
||||
- screen - размер окна
|
||||
- exc_time = время выполнения
|
||||
- actions_result - отчет о выполненых действиях
|
||||
- ok - выполнился ли этот запрос успешно
|
||||
- error - для сообщения об ошибке
|
||||
|
||||
Результат отработки actions
|
||||
|
||||
- type - тип события
|
||||
- ok - успешность события
|
||||
- error - появляется, когда событие сработало с ошибкой
|
||||
- message - появляется, для команд возвращающих результат
|
||||
Описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
|
||||
@@ -10,6 +10,7 @@ apify_fingerprint_datapoints==0.15.0
|
||||
attrs==26.1.0
|
||||
blessed==1.49.0
|
||||
browserforge==1.2.4
|
||||
camoufox==0.5.6
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.5.1
|
||||
click==8.5.0
|
||||
@@ -47,6 +48,7 @@ numpy==2.5.3
|
||||
orjson==3.12.0
|
||||
patchright==1.63.0
|
||||
platformdirs==4.11.7
|
||||
playwright==1.62.0
|
||||
propcache==0.5.2
|
||||
psutil==7.2.2
|
||||
pydantic==2.13.5
|
||||
|
||||
@@ -31,6 +31,10 @@ async def pass_challenges(
|
||||
success_locator: str | None = None,
|
||||
detect_locator: str | None = None,
|
||||
timeout_ms=120_000) -> ChallengeOutcome:
|
||||
if not antibot_name:
|
||||
# провайдер не указан — антибот не проходим, сразу к действиям
|
||||
return ChallengeOutcome(True, reason="antibot not requested")
|
||||
|
||||
# установка дедлайна
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
|
||||
|
||||
@@ -29,4 +29,5 @@ async def select_one(page: Page, provider_name: str, **kwargs) -> AntiBotProvide
|
||||
provider.configure(**kwargs)
|
||||
return provider
|
||||
else:
|
||||
raise ProviderNotDetected()
|
||||
known = ", ".join(p.provider for p in _detectors)
|
||||
raise ProviderNotDetected(f"unknown captcha_type {provider_name!r}, expected one of: {known}")
|
||||
+3
-4
@@ -74,10 +74,7 @@ async def inner_html(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
async def wait_for(page: Page, act: ActionRequest, **kwargs) -> str | None:
|
||||
assert act.selector
|
||||
assert 1 <= act.timeout <= 7200
|
||||
try:
|
||||
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
await page.wait_for_selector(act.selector, timeout=act.timeout * 1000)
|
||||
|
||||
|
||||
@register
|
||||
@@ -92,6 +89,8 @@ async def pass_challenge(
|
||||
success_locator=act.selector or success_locator,
|
||||
detect_locator=detect_locator
|
||||
)
|
||||
if not result.cleared:
|
||||
raise Exception(f"challenge not passed: {result.reason}")
|
||||
return result.reason
|
||||
|
||||
|
||||
|
||||
+9
-12
@@ -76,23 +76,19 @@ async def solve(
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
|
||||
logger.info("Incoming payload: [%s]\n%s", session_id, payload.model_dump_json(indent=2, ensure_ascii=True))
|
||||
|
||||
global BROWSER_SLOTS
|
||||
|
||||
if BROWSER_SLOTS <= 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "No slots available",
|
||||
}
|
||||
BROWSER_SLOTS -= 1
|
||||
|
||||
try:
|
||||
assert BROWSER_SLOTS > 0 and (BROWSER_SLOTS := BROWSER_SLOTS.__sub__(1)) >= 0, "No slots available"
|
||||
|
||||
proxy = parse_proxy(payload.proxy)
|
||||
|
||||
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) — генерируем новый
|
||||
@@ -139,12 +135,13 @@ async def solve(
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
result_payload = {
|
||||
"error": str(e),
|
||||
"error": str(e) or type(e).__name__,
|
||||
"status": False
|
||||
}
|
||||
else:
|
||||
status = not "error" in result["actions"][-1]
|
||||
error = ["Something error", None][status]
|
||||
failed = [a for a in result["actions"] if a.get("ok") is False]
|
||||
status = not failed
|
||||
error = None if status else f"{failed[0]['type']}: {failed[0].get('result')}"
|
||||
|
||||
result_payload = {
|
||||
"status": ["err", "ok"][status],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Нагрузочный тест /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()
|
||||
if "No slots" in str(data.get("error", "")):
|
||||
return "no_slots"
|
||||
actions = data.get("actions") or []
|
||||
if not actions:
|
||||
return f"error:{str(data.get('error'))[:60]}"
|
||||
last = actions[-1]
|
||||
if not last.get("ok") or last.get("result"):
|
||||
html = data.get("html") or ""
|
||||
return "cf_blocked" if any(m in html for m in CF_MARKERS) else "action_failed"
|
||||
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