Compare commits

..
5 Commits
Author SHA1 Message Date
protokeyandClaude Opus 5.5 a319b81d8b build: лимиты ресурсов, прокси и нагрузочный тест; docs: API
- docker-compose: cpus/mem_limit через API_CPUS/API_MEM_LIMIT, прокси gost в профиле loadtest
- tests/loadtest.py: параллельные запросы к /v1/solve + пики docker stats
- docs/API.md: справочник по запросу, действиям, ответу и сессиям
- docs/Deployment.md: результаты замеров и правило подбора BROWSER_SLOTS

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

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

No files matched your search

+12 -50
View File
@@ -57,27 +57,20 @@ 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": "Mozilla/5.0 (...) Chrome/...",
"timeout": 120
"html": true
}
```
@@ -85,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
-15
View File
@@ -1,15 +0,0 @@
# Список задач
## Срочные и Важные
-[ ] не удается пройти cf_turnstile
## Не срочные и Важные
-[ ] добавить пакет обертку для python
## Срочные и Не важные
## Не срочные и Не срочные
-[ ]
+13
View File
@@ -12,6 +12,19 @@ services:
- 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
View File
@@ -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#нагрузка).
+38
View File
@@ -46,9 +46,47 @@ docker compose exec api sh -c 'rm -rf /app/extra/user_data_dir/*'
- `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`:
+4 -55
View File
@@ -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 - User-Agent браузера Chrome, если не передать будет использован свой
- screen - размер окна, по умолчанию: 1280х920
- timeout - общий таймаут выполнения запроса, по умолчанию: 120
- captcha - токен сервиса распознавания, если требуется решить капчу на странице
- actions - перечисление списка действий, которые на странице нужно выполнить
Настройка actions:
- type - тип запроса из перечисления
- selector - css селектор
- value - значение для вставки
- timeout - время выполнения или ожидания
Доступные типы actions:
- wait_for - ожидание появления элемента
- selector
- timeout
- mouse_move - перемещение курсора
- selector
- click - клик
- selector - если не передан, то клик в текущей позиции
- fill - заполняет поля
- selector
- value - значение будет впечатано
Будет добавлено:
1. `{"type": "typing", "value": "user@example.com"},` - набор текста
2. `{"type": "innerhtml", "selector": "#email"},` - получить контент
3. `{"type": "script", "source": "console.log(\"hello world\")"},` - выполнить скрипт
**Ответ**:
- URL - итоговый урл из адресной строки на момент окончания работы
- user_agent - заголовок браузера
- screen - размер окна
- exc_time = время выполнения
- actions_result - отчет о выполненых действиях
- ok - выполнился ли этот запрос успешно
- error - для сообщения об ошибке
Результат отработки actions
- type - тип события
- ok - успешность события
- error - появляется, когда событие сработало с ошибкой
- message - появляется, для команд возвращающих результат
Описание запроса и ответа — [API.md](API.md), развёртывание — [Deployment.md](Deployment.md).
+4
View File
@@ -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
+2 -1
View File
@@ -29,4 +29,5 @@ async def select_one(page: Page, provider_name: str, **kwargs) -> AntiBotProvide
provider.configure(**kwargs)
return provider
else:
raise ProviderNotDetected()
known = ", ".join(p.provider for p in _detectors)
raise ProviderNotDetected(f"unknown captcha_type {provider_name!r}, expected one of: {known}")
+3 -4
View File
@@ -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
View File
@@ -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],
+137
View File
@@ -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())