diff --git a/README.md b/README.md index 0936c25..f390def 100644 --- a/README.md +++ b/README.md @@ -57,27 +57,20 @@ requirements.txt env.example ``` -## API (draft) +## API ``` POST /v1/solve X-API-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": "...", - "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": "......" } ``` -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 diff --git a/docker-compose.yml b/docker-compose.yml index e6cf55a..658300e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f8b5bbc --- /dev/null +++ b/docs/API.md @@ -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: +Cookie: session_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": "......" +} +``` + +| Поле | Описание | +|---|---| +| `status` | `"ok"` — все действия выполнены, `"err"` — одно из действий упало (выполнение на нём остановлено) | +| `user_agent` | UA браузера, с которым проходили антибот (нужен клиенту вместе с `cf_clearance`) | +| `cookies` | все cookies контекста | +| `actions` | по элементу на выполненное действие: `type`, `ok`, `result` (результат или текст ошибки) | +| `html` | только при `"html": true` | +| `error` | при `status: "err"`: `: <текст ошибки>` упавшего действия | + +Ошибка с упавшим действием: + +```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/`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare +обычно не нужно. Без cookie каждый запрос получает новый профиль. + +Один профиль нельзя использовать в двух запросах одновременно: Chrome заблокирует каталог профиля. + +## Производительность + +Каждый запрос запускает отдельный Chrome: около 0,6 ГБ памяти и до 3–4 ядер на пике при прохождении Cloudflare. +Замеры и рекомендации по лимитам — в [Deployment.md](Deployment.md#нагрузка). diff --git a/docs/Deployment.md b/docs/Deployment.md index 3247fdd..20a6b4b 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -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`: diff --git a/docs/Introduction.md b/docs/Introduction.md index 639d410..f5bbae4 100644 --- a/docs/Introduction.md +++ b/docs/Introduction.md @@ -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). diff --git a/tests/loadtest.py b/tests/loadtest.py new file mode 100644 index 0000000..90fe47e --- /dev/null +++ b/tests/loadtest.py @@ -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())