10 Commits
Author SHA1 Message Date
protokeyandClaude Opus 5.5 f6ddf48a13 fix: make test-browser — client/src в контейнере для tests/unit/test_client.py
pytest собирает и unit-тесты, без client/src падал import robotex на этапе сбора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:57:49 +04:00
protokeyandClaude Opus 5.5 232385f8d8 fix: Chrome без менеджера паролей, перевода и автозаполнения (#111)
Перед каждым запуском окна в Default/Preferences профиля дописываются prefs:
credentials_enable_service, profile.password_manager_enabled, translate.enabled,
autofill.* = false. Применяется и к новым, и к существующим профилям, остальные
настройки Chrome не трогаются. Пузырь «Сохранить пароль?» и предложение
перевода больше не появляются, пароли не оседают в Login Data профиля.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:54:40 +04:00
protokeyandClaude Opus 5.5 677fea0090 chore: клиент robotex 0.2.0 — Action.captcha_image (#109)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:20:56 +04:00
protokeyandClaude Opus 5.5 5ed2cfbb3d feature: действие captcha value=image — капча-картинка через 2captcha (#109)
Снимок элемента картинки на текущей странице (без повторной загрузки src) уходит
в 2captcha как обычная капча (method=base64), ответ набирается в поле target
как у typing и возвращается в result действия. Необязательные подсказки:
min_len, max_len, numeric, lang, case_sensitive. Клиент: Action.captcha_image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:17:25 +04:00
protokeyandClaude Opus 5.5 502b6e1003 fix: имя воркера — свободное worker-N из Redis, а не hostname (#110)
Два процесса на одном хосте получали один WORKER_ID и делили персональную
очередь: задачу без url в открытое окно забирал процесс без этого окна.

Теперь при старте воркер занимает наименьшее свободное имя worker-N
(SET NX на heartbeat-ключ), heartbeat продлевает его только при своём
instance; если имя занял другой процесс — воркер останавливается.
Имена и персональные очереди переиспользуются. WORKER_ID из env убран,
в /health — instance, host, pid. make dev при выходе гасит и api, и worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:02:30 +04:00
protokey 1f7d60563d chore: тесты прода 2026-10-14 13:18:14 +04:00
protokeyandClaude Opus 5.5 9d616e1f4f chore: make release — образы в реестр по умолчанию, архив по out=file
- out=registry (по умолчанию): release зависит от push, в build/<ver>/ —
  только compose, .env и README с docker compose pull.
- out=file: как раньше — образы и rabbitmq/redis/postgres в .tar.gz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:21:24 +04:00
protokeyandClaude Opus 5.5 0e10fb671d fix: reCAPTCHA — закрытие окна с картинками и вызов callback (#10)
- Окно с картинками закрывается кликом в угол подложки: в её центре лежит
  само окно и перехватывает клик (Locator.click падал по таймауту 60 с).
- form_submit выполняется в основном мире страницы (isolated_context=False):
  префикс "mw:" patchright не поддерживает, в изолированном мире
  ___grecaptcha_cfg не виден — "Recaptcha callback function not found".
- Воркер: LOG_LEVEL выставляется корневому логгеру явно — dramatiq настраивает
  логирование раньше, и basicConfig ничего не делал. make dev — LOG_LEVEL=DEBUG.
- test_main.http: запрос на zennolab с проверкой результата по #main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:17:06 +04:00
protokeyandClaude Opus 5.5 4d90dc4a21 fix: reCAPTCHA — окно с картинками определяется по положению (#10)
- Маркер интерактивного челленджа: iframe bframe, контейнер которого не стоит
  за экраном (top: -10000px); click_and_race ждёт видимости, а не появления в DOM.
- Маркер наличия капчи берётся из исходного HTML (api.js / enterprise.js,
  .g-recaptcha, iframe чекбокса), проверка мгновенная, без ожидания iframe.
- SolverReCaptcha ждёт detect_locator в DOM, а не видимости (script невидим).
- test_main.http: секреты вынесены в http-client.private.env.json, запрос
  на стенд zennolab, исправлены XPath с @class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:30:29 +04:00
protokeyandClaude Opus 5.5 fdee12e2d8 feature: make dev — api и worker на хосте с видимым окном Chrome (#90)
- docker-compose.dev.yml: порты rabbitmq, redis, postgres на 127.0.0.1 (5673, 6380, 5433 —
  стандартные часто заняты другими проектами)
- make dev: останавливает контейнерные api и worker (иначе worker в Docker тоже берёт задачи
  из общей очереди и выполняет их невидимо), поднимает инфраструктуру, запускает uvicorn --reload
  на :8010 и воркер dramatiq; Ctrl+C останавливает оба
- make dev-key name=...: API-ключ без контейнера api
- README: раздел Local development

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:01:05 +04:00
35 changed files with 804 additions and 79 deletions

No files matched your search

+1 -1
View File
@@ -12,5 +12,5 @@ tab_width = 4
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}]
indent_size = 2
[{*.yaml,*.yml,environment.yaml,environment.yml}]
[{*.yaml,*.yml,*.http,environment.yaml,environment.yml}]
indent_size = 2
+1
View File
@@ -9,3 +9,4 @@ build/
client/dist/
client/build/
client/src/*.egg-info/
http-client.private.env.json
+61 -12
View File
@@ -5,10 +5,10 @@ WHEELS_DIR := wheels
REQUIREMENTS := requirements.txt
ver ?= latest
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push dev dev-deps dev-key
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.PHONY: install freeze wheels sync sync-offline clean help venv build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push
.PHONY: install freeze wheels sync sync-offline clean help venv build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push dev dev-deps dev-key
# образы сервисов: api — без Chrome, worker — Chrome + Xvfb; rabbitmq и redis — сторонние, кладутся в релиз
REGISTRY := docker.sokolab.xyz
@@ -18,6 +18,24 @@ DEPS_IMAGES := rabbitmq:4.1-alpine redis:8.2-alpine postgres:17-alpine
API_URL ?= http://localhost:8000
API_KEY ?=
# make dev (#90): rabbitmq, redis, postgres — в Docker с портами на 127.0.0.1 (docker-compose.dev.yml), api и worker —
# на хосте, Chrome открывается на рабочем столе. Порты нестандартные: 5672/5432/8000 часто заняты другими проектами.
DEV_COMPOSE := docker compose -f docker-compose.yml -f docker-compose.dev.yml
DEV_API_PORT ?= 8010
DEV_RABBITMQ_PORT ?= 5673
DEV_REDIS_PORT ?= 6380
DEV_POSTGRES_PORT ?= 5433
# окон Chrome на рабочем столе; потоков dramatiq — вдвое больше, как в Dockerfile
DEV_SLOTS ?= 2
RABBITMQ_PASSWORD ?= robotex
POSTGRES_PASSWORD ?= robotex
export DEV_RABBITMQ_PORT DEV_REDIS_PORT DEV_POSTGRES_PORT RABBITMQ_PASSWORD POSTGRES_PASSWORD
DEV_ENV := RABBITMQ_URL=amqp://robotex:$(RABBITMQ_PASSWORD)@localhost:$(DEV_RABBITMQ_PORT)/ \
REDIS_URL=redis://localhost:$(DEV_REDIS_PORT)/0 \
DATABASE_URL=postgresql+asyncpg://robotex:$(POSTGRES_PASSWORD)@localhost:$(DEV_POSTGRES_PORT)/robotex \
BROWSER_SLOTS=$(DEV_SLOTS) \
LOG_LEVEL=DEBUG
help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@echo "make sync - установить все пакеты из $(REQUIREMENTS) (из индекса)"
@@ -27,10 +45,13 @@ help:
@echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make build [ver=latest] - собрать образы $(API_IMAGE):<ver> и $(WORKER_IMAGE):<ver>"
@echo "make push ver=<ver> - собрать образы и отправить в $(REGISTRY)"
@echo "make release ver=<ver> - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env, образы .tar.gz)"
@echo "make release ver=<ver> [out=registry|file] - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env);"
@echo " образы: registry — push в $(REGISTRY) (по умолчанию), file — архив .tar.gz"
@echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis"
@echo "make down - остановить локальный стенд"
@echo "make logs - логи api и worker"
@echo "make dev - api и worker на хосте с видимым Chrome, rabbitmq/redis/postgres в Docker; API на :$(DEV_API_PORT)"
@echo "make dev-key name=<клиент> - выпустить API-ключ клиенту при make dev"
@echo "make smoke API_KEY=... [API_URL=...] - проверить запущенный стенд: ключ, /v1/solve, /v2/tasks, удержание окна"
@echo "make key-create name=<клиент> - выпустить API-ключ клиенту (в запущенном compose)"
@echo "make key-list - клиенты и их ключи (без самих ключей)"
@@ -88,6 +109,24 @@ down:
logs:
docker compose logs -f api worker
# контейнерные api и worker останавливаются: иначе worker в Docker тоже берёт задачи из общей очереди и выполняет их невидимо
dev-deps:
-docker compose stop api worker
$(DEV_COMPOSE) up -d --wait rabbitmq redis postgres
# Ctrl+C останавливает оба процесса; инфраструктура остаётся запущенной (make down — остановить)
# api и worker — в фоне: фоновые процессы sh не получают SIGINT от Ctrl+C, поэтому при выходе (Ctrl+C, закрытие
# терминала, kill) trap останавливает оба и ждёт их — иначе осиротевший worker остаётся работать (#110)
dev: dev-deps
cd src && export $(DEV_ENV) && \
{ $(PYTHON) -m uvicorn main:app --reload --port $(DEV_API_PORT) & api=$$!; \
$(PYTHON) -m dramatiq worker.actors --processes 1 --threads $$(( $(DEV_SLOTS) * 2 )) & worker=$$!; \
trap 'trap - EXIT INT TERM HUP; kill $$api $$worker 2>/dev/null; wait' EXIT INT TERM HUP; \
wait $$worker; }
dev-key: dev-deps
cd src && $(DEV_ENV) $(PYTHON) -m db.cli create "$(name)"
smoke:
$(PYTHON) tests/smoke.py $(API_URL) $(API_KEY)
@@ -118,38 +157,48 @@ client-build: venv
test-browser: build
docker run --rm --shm-size=2g \
-v $(CURDIR)/tests:/app/tests:ro -v $(CURDIR)/pytest.ini:/app/pytest.ini:ro \
-v $(CURDIR)/requirements-dev.txt:/app/requirements-dev.txt:ro \
-v $(CURDIR)/requirements-dev.txt:/app/requirements-dev.txt:ro -v $(CURDIR)/client/src:/app/client/src:ro \
-w /app $(WORKER_IMAGE):$(ver) sh -c 'pip install -q --user -r requirements-dev.txt && \
xvfb-run -a --server-args="-screen 0 1920x1080x24" python -m pytest -m browser -p no:cacheprovider -s'
RELEASE_DIR := build/$(ver)
# куда деваются образы при make release: registry — push в $(REGISTRY), file — архив .tar.gz в $(RELEASE_DIR)/
out ?= registry
ifneq ($(filter release push,$(MAKECMDGOALS)),)
ifeq ($(ver),latest)
$(error Укажите версию: make release ver=v0.0.2)
endif
ifeq ($(filter $(out),registry file),)
$(error out=registry или out=file, а не "$(out)")
endif
endif
release: build
@echo "make release $(ver) -> $(RELEASE_DIR)/"
ifeq ($(out),file)
RELEASE_RUN := docker load -i robotex-$(ver).tar.gz
else
RELEASE_RUN := docker compose pull
endif
release: $(if $(filter file,$(out)),build,push)
@echo "make release $(ver) [out=$(out)] -> $(RELEASE_DIR)/"
@mkdir -p $(RELEASE_DIR)
sed -e 's|image: .*$(API_IMAGE):.*|image: $(REGISTRY)/$(API_IMAGE):$(ver)|' \
-e 's|image: .*$(WORKER_IMAGE):.*|image: $(REGISTRY)/$(WORKER_IMAGE):$(ver)|' \
docker-compose.prod.yml > $(RELEASE_DIR)/docker-compose.yml
cp dot_env $(RELEASE_DIR)/.env
ifeq ($(out),file)
# сторонние образы (rabbitmq, redis, postgres) кладутся в архив; при out=registry сервер берёт их с Docker Hub
for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done
# TODO: я хочу парамтером управлять сохранить в файл или отправить в свой реджестри
# if produce == file:
docker tag $(API_IMAGE):$(ver) $(REGISTRY)/$(API_IMAGE):$(ver)
docker tag $(API_IMAGE):$(ver) $(REGISTRY)/$(API_IMAGE):$(ver)
docker tag $(WORKER_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver)
docker save $(REGISTRY)/$(API_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver) $(DEPS_IMAGES) | gzip > $(RELEASE_DIR)/robotex-$(ver).tar.gz
# else:
# docker push docker.sokolab.xyz/$(API_IMAGE):$(ver) docker.sokolab.xyz/$(WORKER_IMAGE):$(ver) docker.sokolab.xyz/$(DEPS_IMAGES)
endif
@printf '%s\n' \
'# Robotex $(ver)' \
'' \
'```bash' \
'docker load -i robotex-$(ver).tar.gz' \
'$(RELEASE_RUN)' \
'docker compose up -d' \
'```' \
'' \
+12 -8
View File
@@ -152,7 +152,7 @@ retries) — [docs/Usage.md](docs/Usage.md).
Python client: [client/README.md](client/README.md).
```bash
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.1.0#subdirectory=client"
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.2.0#subdirectory=client"
```
## Roadmap
@@ -167,14 +167,17 @@ Not done yet:
## Local development
```bash
make sync-offline # venv in ~/.py_envs/robotex; another path: make VENV=... <target>
docker compose up -d rabbitmq redis # queue and storage
cd src && RABBITMQ_URL=... REDIS_URL=... uvicorn main:app --reload
cd src && dramatiq worker.actors --processes 1 --threads 4
make sync-offline # venv in ~/.py_envs/robotex; another path: make VENV=... <target>
make dev # api + worker on the host, Chrome windows visible on your desktop; API on :8010
make dev-key name=local # API key for it (stored in the same postgres volume as make up)
```
The compose network is not published, so for running the services on the host publish `5672` and `6379` yourself,
or just use `make up`. Chrome runs headful, so a display is required (locally — your desktop, in Docker — Xvfb).
`make dev` stops the containerized api and worker (otherwise the Docker worker also takes tasks from the common
queue and runs them invisibly), starts rabbitmq, redis and postgres with ports published on 127.0.0.1
(`docker-compose.dev.yml`: 5673, 6380, 5433), then runs uvicorn with `--reload` and the dramatiq worker in the
foreground. Ctrl+C stops both; `make down` stops the infrastructure. Ports, slots: `DEV_API_PORT`,
`DEV_RABBITMQ_PORT`, `DEV_REDIS_PORT`, `DEV_POSTGRES_PORT`, `DEV_SLOTS` (default 2). Chrome runs headful, so a display
is required (locally — your desktop, in Docker — Xvfb).
Tests:
@@ -201,7 +204,8 @@ Releases:
```bash
make build ver=v0.1.0 # docker images robotex-api:v0.1.0 and robotex-worker:v0.1.0
make release ver=v0.1.0 # images + deploy bundle in build/v0.1.0/ (compose, .env, images .tar.gz)
make release ver=v0.1.0 # push images to docker.sokolab.xyz + deploy bundle in build/v0.1.0/ (compose, .env)
make release ver=v0.1.0 out=file # same, but images go to build/v0.1.0/robotex-v0.1.0.tar.gz instead of the registry
```
Details (Xvfb, tini, `/dev/shm`, env settings, load measurements) — [docs/Deployment.md](docs/Deployment.md).
+14 -3
View File
@@ -6,13 +6,13 @@
## Установка
```bash
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.1.0#subdirectory=client"
pip install "robotex @ git+https://git.sokolab.xyz/s0k0l/robotex.git@v0.2.0#subdirectory=client"
```
или из wheel (`make client-build` в репозитории кладёт его в `client/dist/`):
```bash
pip install robotex-0.1.0-py3-none-any.whl
pip install robotex-0.2.0-py3-none-any.whl
```
## Быстрый старт
@@ -100,5 +100,16 @@ async def main():
## Действия
`Action.fill`, `typing`, `click`, `submit`, `mouse_move`, `delay(seconds)`, `wait_for(selector, timeout=None)`,
`inner_html`, `inner_text`, `captcha(selector, kind)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
`inner_html`, `inner_text`, `captcha(selector, kind)`, `captcha_image(selector, target, **hints)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
API: `{"type": "click", "selector": "#a"}`.
Капча-картинка («код с картинки»): снимок элемента уходит в 2captcha, ответ набирается в поле и возвращается
в `result`. Подсказки: `min_len`, `max_len`, `numeric` (1 — цифры, 2 — буквы), `lang`, `case_sensitive`.
```python
res = s.run(actions=[
Action.captcha_image("#popup-signup img.captcha", '#popup-signup input[name="captcha"]', min_len=4),
Action.submit("#popup-signup button[type=submit]"),
], captcha="<ключ 2captcha>")
code = res.results[0]
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "robotex"
version = "0.1.0"
version = "0.2.0"
description = "Python-клиент Robotex API: браузер с обходом антиботов, задачи v2 и окна сессий"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -21,7 +21,7 @@ from robotex.errors import (
)
from robotex.models import ActionResult, Result, Task
__version__ = "0.1.0"
__version__ = "0.2.0"
__all__ = [
"Action", "ActionResult", "AsyncRobotex", "AsyncSession", "AsyncTaskRef", "AuthError", "InvalidRequest",
+24 -1
View File
@@ -11,6 +11,13 @@ class Action:
selector: str | None = None
value: str | None = None
timeout: int | None = None
# только для captcha_image
target: str | None = None
min_len: int | None = None
max_len: int | None = None
numeric: int | None = None
lang: str | None = None
case_sensitive: bool | None = None
def to_dict(self) -> dict[str, Any]:
return {k: v for k, v in vars(self).items() if v is not None}
@@ -60,9 +67,25 @@ class Action:
def captcha(cls, selector: str, kind: str) -> "Action":
"""Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ)."""
if kind not in CAPTCHA_TYPES:
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)}")
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)} (image: use captcha_image)")
return cls("captcha", selector, kind)
@classmethod
def captcha_image(
cls, selector: str, target: str, *,
min_len: int | None = None, max_len: int | None = None, numeric: int | None = None,
lang: str | None = None, case_sensitive: bool | None = None,
) -> "Action":
"""Капча-картинка: снимок элемента selector уходит в 2captcha, ответ набирается в поле target
и возвращается в result; нужен параметр captcha (ключ).
Подсказки 2captcha: min_len/max_len — 1–20 символов; numeric — 1 только цифры, 2 только буквы,
3 цифры или буквы, 4 и цифры, и буквы; lang — код языка; case_sensitive — учитывать регистр."""
return cls(
"captcha", selector, "image", target=target, min_len=min_len, max_len=max_len,
numeric=numeric, lang=lang, case_sensitive=case_sensitive,
)
@classmethod
def pass_challenge(cls) -> "Action":
"""Пройти антибот на текущей странице (например, после submit)."""
+15
View File
@@ -0,0 +1,15 @@
# Локальная разработка (make dev, #90): поверх docker-compose.yml открывает порты rabbitmq, redis и postgres
# на 127.0.0.1, чтобы api и worker запускались на хосте — с видимым окном Chrome на рабочем столе.
# Порты нестандартные: 5672/5432 на хосте часто заняты другими проектами.
services:
rabbitmq:
ports:
- "127.0.0.1:${DEV_RABBITMQ_PORT:-5673}:5672"
redis:
ports:
- "127.0.0.1:${DEV_REDIS_PORT:-6380}:6379"
postgres:
ports:
- "127.0.0.1:${DEV_POSTGRES_PORT:-5433}:5432"
+12
View File
@@ -82,8 +82,20 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `delay` | `value` (секунды строкой, 0–600) | пауза |
| `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` |
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` в запросе |
| `captcha` (`value: "image"`) | `selector` (картинка), `target` (поле ввода); подсказки необязательны: `min_len`, `max_len` (1–20), `numeric`, `lang`, `case_sensitive` | капча-картинка «код с картинки»: скриншот элемента `selector` на текущей странице (без повторной загрузки, код не меняется) уходит в 2captcha, ответ набирается в `target` как у `typing` и возвращается в `result`. Не решена или таймаут 2captcha — ошибка действия. `numeric`: `1` — только цифры, `2` — только буквы, `3` — цифры или буквы, `4` — и цифры, и буквы |
| `pass_challenge` | `selector` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия |
Капча-картинка в форме регистрации — решить, затем отправить форму. Если сайт отклонил код и показал новую картинку,
повторите задачу v2 в открытом окне сессии (`browser_idle_timeout`): страница не перезагружается.
```json
{"captcha": "<ключ 2captcha>", "browser_idle_timeout": 5, "actions": [
{"type": "captcha", "selector": "#popup-signup img.captcha", "value": "image",
"target": "#popup-signup input[name=\"captcha\"]", "min_len": 4, "max_len": 6},
{"type": "submit", "selector": "#popup-signup button[type=submit]"}
]}
```
## Ответ
Формат один для всех случаев: `status` — `"ok"` или `"err"`, при ошибке — `code` и `error`. Поля без значения не
+5 -1
View File
@@ -32,6 +32,11 @@ API доступно на `http://localhost:8000`.
а задачи сессии с открытым окном api направляет в персональную очередь воркера, где это окно. Профили сессий лежат
в volume воркера — воркеры на одном хосте делят его.
Имя воркера (`worker-1`, `worker-2`, …) не настраивается: при старте каждый процесс занимает в Redis наименьшее
свободное имя, поэтому два воркера на одном хосте не делят персональную очередь, а после перезапусков имена и
очереди переиспользуются. Если имя за время недоступности Redis занял другой процесс, воркер останавливается,
и Docker перезапускает его с новым именем. Где работает воркер — поля `host` и `pid` в `/health`.
## Клиенты и ключи
Без ключей API никого не пускает. Ключ показывается один раз при выпуске, в базе хранится только sha256.
@@ -111,7 +116,6 @@ docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*'
|---|---|---|
| `BROWSER_SLOTS` | `4` | worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди |
| `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq |
| `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди |
| `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи |
| `PROFILE_TTL_DAYS` | `7` | worker: через сколько дней без использования удаляется профиль браузера |
| `V1_QUEUE_WAIT` | `30` | api: сколько `/v1/solve` ждёт, пока воркер возьмёт задачу; дальше `503` |
+3 -1
View File
@@ -23,8 +23,10 @@ async def click_and_race(page, element, challenge_locator, timeout=30_000):
timeout=timeout,
)
)
# challenge_locator должен совпадать только с открытым окном челленджа: элементы челленджа могут быть в DOM
# заранее (reCAPTCHA держит их за экраном), поэтому ждём видимости, а не появления
challenge_task = asyncio.create_task(
challenge_locator.wait_for(state="attached", timeout=timeout)
challenge_locator.first.wait_for(state="visible", timeout=timeout)
)
await element.click()
+28 -12
View File
@@ -1,4 +1,3 @@
import json
import logging
from patchright.async_api import Page
@@ -6,12 +5,26 @@ from patchright.async_api import Page
from captcha.base import Captcha
from ..base import Detector, ChallengeInfo, Solver, AntiBotProvider, click_and_race
from ..base import Stage
from ..base import Stage, PWTimeoutError
from ..registry import register
from ..exceptions import IframeLoadError
log = logging.getLogger(__name__)
# iframe окна с картинками (bframe) вставляется в DOM вместе с чекбоксом, но его контейнер стоит за экраном:
# style="visibility: hidden; ... top: -10000px". Когда выпадает челлендж, контейнер переезжает на экран и
# становится видимым — локатор совпадает только с открытым окном
INTERACTIVE_MARKER_SELECTOR = (
"xpath=//iframe[contains(@src, '/bframe')][not(ancestor::div[contains(@style, 'top: -10000px')])]"
)
# признак reCAPTCHA, который есть уже в исходном HTML, до загрузки iframe с чекбоксом (он грузится долго):
# скрипт api.js / enterprise.js, контейнер .g-recaptcha (у render=explicit его может не быть) или сам iframe.
# #recaptcha-token не подходит — он внутри iframe чекбокса, с основной страницы его не видно
DETECT_MARKER_SELECTOR = (
"script[src*='/recaptcha/api.js'], script[src*='/recaptcha/enterprise.js'], "
".g-recaptcha, iframe[title='reCAPTCHA']"
)
JS_CHALLENGE_SUBMIT = """(token) => {
const clients = window.___grecaptcha_cfg && window.___grecaptcha_cfg.clients;
@@ -42,8 +55,8 @@ class ReCaptchaSolver(Solver):
self.captcha_solver: Captcha = self._opt["captcha_solver"]
async def form_submit(self, token: str) -> bool:
# noinspection UnnecessaryLabelJS
return await self.page.evaluate(f"mw:({JS_CHALLENGE_SUBMIT})({json.dumps(token)})")
# ___grecaptcha_cfg живёт в основном мире страницы, а patchright по умолчанию выполняет скрипт в изолированном
return await self.page.evaluate(JS_CHALLENGE_SUBMIT, token, isolated_context=False)
async def solve(self, page: Page, info: ChallengeInfo):
self.page = page
@@ -55,18 +68,22 @@ class ReCaptchaSolver(Solver):
log.debug("Нужно тыкнуть чекбокс")
checkbox_selector = "#recaptcha-anchor"
element = page.frame_locator("iframe[title='reCAPTCHA']").locator(checkbox_selector)
# TODO: маркер всегда присутствует
interactive_marker_selector = "//div[@class='g-recaptcha-bubble-arrow']/..//iframe"
challenge_locator = page.locator(interactive_marker_selector)
challenge_locator = page.locator(INTERACTIVE_MARKER_SELECTOR)
result = await click_and_race(page, element, challenge_locator)
log.debug(f"Ожидание завершено: {result}")
log.debug("Возврат к детекту")
return
else:
log.debug("Нужно решать капчу. Закрою окошко с картинками.")
# полупрозрачная подложка на весь экран (position: fixed), клик по ней закрывает окно. В её центре лежит
# само окно с картинками и перехватывает клик — поэтому кликаем в угол
element = page.locator("xpath=//div[@class='g-recaptcha-bubble-arrow']/../div[1]")
if await element.is_visible():
await element.click()
try:
await element.click(position={"x": 1, "y": 1}, timeout=5_000)
except PWTimeoutError:
# не закрылось — не страшно, токен уходит через callback
log.debug("Окошко с картинками закрыть не удалось")
log.debug("Решаем капчу...")
assert info.detect_locator
@@ -87,10 +104,9 @@ class ReCaptchaDetector(Detector):
log.debug("ReCaptchaDetector: попытка определить есть ли преграда")
info.meta = {}
checkbox_selector = "#recaptcha-anchor"
interactive_marker_selector = "//div[@class='g-recaptcha-bubble-arrow']/..//iframe"
# попытка определить что мы вообще на странице блокировки
marker_selector = info.detect_locator or "#captcha-form"
marker_selector = info.detect_locator or DETECT_MARKER_SELECTOR
info.detect_locator = marker_selector
log.debug(f"ищем по detect: {marker_selector} success: {info.success_locator}")
@@ -111,7 +127,7 @@ class ReCaptchaDetector(Detector):
log.debug("Вижу что это страница с проверкой. Определяю состояние.")
iframe = page.frame_locator("iframe[title='reCAPTCHA']")
# ожидаем когда будет загружен чекбокс
# маркер есть — значит капча будет, ждём пока догрузится iframe с чекбоксом
info.stage = Stage.INTERSTITIAL
checkbox = iframe.locator(checkbox_selector)
@@ -126,7 +142,7 @@ class ReCaptchaDetector(Detector):
log.debug("Чек бокс найден")
info.stage = Stage.CHECKBOX
if await page.locator(interactive_marker_selector).count() > 0:
if await page.locator(INTERACTIVE_MARKER_SELECTOR).first.is_visible():
log.debug("Нужно решать капчу")
# нужно решение
info.stage = Stage.INTERACTIVE
+7 -2
View File
@@ -7,7 +7,7 @@ from patchright.async_api import Page
from antibot.humanize import typing as human_typing
from antibot.orchestrator import pass_challenges
from captcha import load_captcha
from api.schemas import ActionRequest
from api.schemas import ActionRequest, CAPTCHA_IMAGE_HINTS
_actions: dict = {}
@@ -42,7 +42,12 @@ async def typing(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
captcha_solver = load_captcha(captcha_key, act.value)
await captcha_solver.solve(act.selector, page)
if act.value != "image":
await captcha_solver.solve(act.selector, page)
return None
# у картинки распознанный текст короткий и нужен клиенту — возвращаем в result (#109)
hints = act.model_dump(include=set(CAPTCHA_IMAGE_HINTS), exclude_none=True)
return await captcha_solver.solve(act.selector, page, target=act.target, **hints)
@register
+36
View File
@@ -13,6 +13,7 @@ navigator.userAgentData и движком, и Cloudflare такой браузе
"""
import json
import logging
import os
import random
from pathlib import Path
@@ -24,6 +25,16 @@ SETTINGS_FILE = "robotex_session.json"
# окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна
MIN_SCREEN = (1920, 1080)
CANVAS_NOISE_JS = (Path(__file__).parent / "js" / "canvas_noise.js").read_text()
# prefs Chrome, которые задаются профилю при каждом запуске (#111): без пузыря «Сохранить пароль?»,
# предложения перевода и автозаполнения — секреты клиентов не оседают в Login Data/Web Data профиля.
# --disable-translate в современных Chrome не действует, поэтому через prefs. На JS-отпечаток не влияют.
CHROME_PREFS = {
"credentials_enable_service": False,
"credentials_enable_autosignin": False,
"profile": {"password_manager_enabled": False, "password_manager_leak_detection": False},
"translate": {"enabled": False},
"autofill": {"enabled": False, "profile_enabled": False, "credit_card_enabled": False},
}
def new_canvas_seed() -> int:
@@ -81,3 +92,28 @@ def browser_kwargs(settings: dict) -> dict:
"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]},
"init_script_content": CANVAS_NOISE_JS.replace("__SEED__", str(int(fp["canvas_seed"]))),
}
def _merge(target: dict, source: dict) -> dict:
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
_merge(target[key], value)
else:
target[key] = value
return target
def apply_chrome_prefs(user_data_dir: Path) -> None:
"""Дописать CHROME_PREFS в Default/Preferences профиля, не трогая остальное. Вызывается до запуска
Chrome: и для нового профиля, и для существующего — Chrome при выходе сохраняет эти prefs как есть."""
path = user_data_dir / "Default" / "Preferences"
try:
prefs = json.loads(path.read_text())
if not isinstance(prefs, dict):
prefs = {}
except (OSError, json.JSONDecodeError):
prefs = {}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".robotex")
tmp.write_text(json.dumps(_merge(prefs, CHROME_PREFS), ensure_ascii=False))
os.replace(tmp, path)
+20 -1
View File
@@ -3,7 +3,9 @@ from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha", "image")
# подсказки 2captcha для капчи-картинки (#109): разрешены только с captcha value=image
CAPTCHA_IMAGE_HINTS = ("min_len", "max_len", "numeric", "lang", "case_sensitive")
# максимум browser_idle_timeout в минутах: открытое окно держит слот браузера (#78)
MAX_BROWSER_IDLE_TIMEOUT = 120
PROXY_SCHEMES = ("http", "https", "socks5")
@@ -36,6 +38,14 @@ class ActionRequest(BaseModel):
selector: str | None = None
value: str | None = None
timeout: int | None = None
# captcha value=image: поле, куда вводится распознанный текст, и подсказки для 2captcha
target: str | None = None
min_len: int | None = Field(None, ge=1, le=20)
max_len: int | None = Field(None, ge=1, le=20)
# 1 — только цифры, 2 — только буквы, 3 — цифры или буквы, 4 — и цифры, и буквы
numeric: int | None = Field(None, ge=0, le=4)
lang: str | None = None
case_sensitive: bool | None = None
@model_validator(mode="after")
def check_fields(self):
@@ -60,6 +70,15 @@ class ActionRequest(BaseModel):
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)}")
is_image = self.type == "captcha" and self.value == "image"
if is_image and not self.target:
raise ValueError("captcha 'image' requires: target")
extra = [f for f in ("target", *CAPTCHA_IMAGE_HINTS) if getattr(self, f) is not None]
if extra and not is_image:
raise ValueError(f"{', '.join(extra)}: only for action 'captcha' with value 'image'")
if self.min_len and self.max_len and self.min_len > self.max_len:
raise ValueError("min_len must not exceed max_len")
return self
+1
View File
@@ -1,4 +1,5 @@
from .hcaptcha import SolverHCaptcha
from .recaptcha import SolverReCaptcha
from .turnstile import SolverTurnstile
from .image import SolverImage
from .base import load_captcha
+7 -1
View File
@@ -14,7 +14,8 @@ class Captcha:
def get_balance(self) -> float:
return self._solver.balance()
async def solve(self, selector: str, page: Page) -> str:
async def solve(self, selector: str, page: Page, **options) -> str:
"""options — поля действия, нужные конкретному типу (например, target у image)."""
raise NotImplementedError()
async def _run(self, fun, /, *args, **kwargs):
@@ -29,7 +30,12 @@ class Captcha:
resp = await self._run(self._solver.recaptcha, site_key, url)
return resp
async def image(self, body: str, **params):
resp = await self._run(self._solver.solve, method="base64", body=body, **params)
return resp
async def turnstile(self, url, site_key):
# TODO: not finished!
resp = await self._run(self._solver.turnstile, site_key, url)
return resp
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
class SolverHCaptcha(Captcha):
ctype = "hcaptcha"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
# проверка что элемент с капчей есть
div_container = page.locator(selector)
await div_container.wait_for()
+48
View File
@@ -0,0 +1,48 @@
"""
Капча-картинка: изображение с текстом и поле ввода (#109).
Картинку генерирует сайт в сессии браузера, и повторная загрузка по src меняет код,
поэтому снимаем скриншот самого элемента на текущей странице и отправляем его
в 2captcha как обычную капчу (method=base64). Ответ вводится в поле по-человечески.
"""
import base64
import logging
from antibot.humanize import typing as human_typing
from .base import Captcha, Page
logger = logging.getLogger(__name__)
# имя поля запроса -> параметр 2captcha
_HINTS = {
"min_len": "minLen",
"max_len": "maxLen",
"numeric": "numeric",
"lang": "lang",
"case_sensitive": "caseSensitive",
}
class SolverImage(Captcha):
ctype = "image"
async def solve(self, selector, page: Page, *, target: str, **hints):
image = page.locator(selector).first
await image.wait_for()
# тег <img> может появиться раньше, чем загрузится картинка; другие элементы (canvas, div с фоном) снимаем как есть
await image.evaluate(
"el => el.tagName !== 'IMG' || el.complete || new Promise(r => {"
"el.addEventListener('load', r, {once: true}); el.addEventListener('error', r, {once: true});"
"})"
)
body = base64.b64encode(await image.screenshot(type="png")).decode()
params = {
_HINTS[k]: int(v) if isinstance(v, bool) else v
for k, v in hints.items() if k in _HINTS and v is not None
}
text = await self.image(body, **params)
logger.debug("image captcha solved: %r", text)
await human_typing(page, target, text)
return text
+3 -3
View File
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class SolverReCaptcha(Captcha):
ctype = "recaptcha"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
"""
#recaptcha-token
@@ -25,8 +25,8 @@ class SolverReCaptcha(Captcha):
:param page:
:return:
"""
div_container = page.locator(selector)
await div_container.wait_for()
# selector может указывать на невидимый элемент (script) и совпадать с несколькими
await page.locator(selector).first.wait_for(state="attached")
site_key_input_element = page.locator("iframe[title='reCAPTCHA']")
src_value = await site_key_input_element.get_attribute("src")
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
class SolverTurnstile(Captcha):
ctype = "cloudflare"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
container = page.locator(selector)
await container.wait_for()
+49 -5
View File
@@ -6,10 +6,13 @@
* `robotex:session:<id>` (hash) — где открыто окно сессии: `worker_id`, `idle_timeout`, `client_id`.
Пока задача выполняется, TTL — `RUNNING_TTL`; после неё — `browser_idle_timeout`. Ключ исчез —
окно закрывается (worker/windows.py).
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты и число окон.
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты, число окон, `instance` (токен
процесса), `host`, `pid`. Ключ — он же аренда имени: имена `worker-1`, `worker-2`, … переиспользуются,
при старте воркер берёт наименьшее свободное (#110), поэтому персональных очередей не больше, чем воркеров.
Прокси и ключ 2captcha сюда не попадают: они передаются только в сообщении очереди.
"""
import itertools
import json
import os
import time
@@ -25,6 +28,7 @@ PENDING_TTL = 6 * 3600
# пока задача выполняется, ключ сессии держит маршрут к воркеру; дольше задача не живёт (TimeLimit)
RUNNING_TTL = 3 * 3600
HEARTBEAT_TTL = 30
WORKER_NAME_PREFIX = "worker-"
# сколько раз задачу можно начать заново после падения воркера, прежде чем считать её проваленной
MAX_ATTEMPTS = 2
@@ -186,11 +190,51 @@ class TaskStore:
# --- воркеры ---
async def heartbeat(self, worker_id: str, info: dict) -> None:
await self.redis.set(_worker_key(worker_id), json.dumps(info), ex=HEARTBEAT_TTL)
async def acquire_worker_name(self, info: dict) -> str:
"""Занять наименьшее свободное имя worker-N. info — heartbeat с instance процесса.
SET NX атомарен: два одновременно стартующих воркера одно имя не возьмут."""
for n in itertools.count(1):
name = f"{WORKER_NAME_PREFIX}{n}"
if await self.redis.set(_worker_key(name), json.dumps(info), nx=True, ex=HEARTBEAT_TTL):
return name
async def remove_worker(self, worker_id: str) -> None:
await self.redis.delete(_worker_key(worker_id))
async def heartbeat(self, worker_id: str, info: dict) -> bool:
"""Продлить heartbeat, только если имя ещё за этим процессом (info["instance"]).
False — имя занял другой процесс: этот воркер его потерял."""
key = _worker_key(worker_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
current = await pipe.get(key)
if current is not None and json.loads(current).get("instance") != info.get("instance"):
await pipe.unwatch()
return False
pipe.multi()
pipe.set(key, json.dumps(info), ex=HEARTBEAT_TTL)
await pipe.execute()
return True
except WatchError:
continue
async def remove_worker(self, worker_id: str, instance: str | None = None) -> None:
"""Освободить имя. С instance — только если оно ещё за этим процессом."""
key = _worker_key(worker_id)
if instance is None:
await self.redis.delete(key)
return
async with self.redis.pipeline(transaction=True) as pipe:
try:
await pipe.watch(key)
current = await pipe.get(key)
if current is None or json.loads(current).get("instance") != instance:
await pipe.unwatch()
return
pipe.multi()
pipe.delete(key)
await pipe.execute()
except WatchError:
pass
async def worker_alive(self, worker_id: str | None) -> bool:
return bool(worker_id) and bool(await self.redis.exists(_worker_key(worker_id)))
+21 -10
View File
@@ -8,12 +8,15 @@ dramatiq, а не из event loop.
import asyncio
import logging
import os
import socket
import signal
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
level=LOG_LEVEL,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# под dramatiq логирование уже настроено им самим (уровень INFO) и basicConfig ничего не делает — уровень явно
logging.getLogger().setLevel(LOG_LEVEL)
import dramatiq # noqa: E402
from dramatiq.asyncio import get_event_loop_thread # noqa: E402
@@ -27,7 +30,6 @@ from worker.windows import Outcome, WindowPool # noqa: E402
log = logging.getLogger(__name__)
WORKER_ID = os.getenv("WORKER_ID") or socket.gethostname()
# сколько окон Chrome одновременно — и выполняющих задачу, и удерживаемых; ≈ числу ядер (docs/Deployment.md)
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4"))
# через сколько задача без свободного слота снова попадёт в очередь
@@ -65,14 +67,23 @@ async def _cleanup_loop(pool: WindowPool) -> None:
await asyncio.sleep(PROFILE_CLEANUP_INTERVAL)
async def _start() -> None:
def _stop_self() -> None:
"""Имя воркера занял другой процесс: штатная остановка, как по Ctrl+C. dramatiq при выходе дочернего
процесса останавливается целиком, Docker (restart: unless-stopped) запустит воркер заново с новым именем."""
os.kill(os.getpid(), signal.SIGTERM)
async def _start() -> str:
"""Пул окон и имя воркера. Имя (worker-N) — не из env, а свободное в Redis (#110): два процесса на одном
хосте не делят персональную очередь, а имена и очереди переиспользуются после перезапусков."""
global _pool
db = connect_db()
await db.init()
_pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS, Journal(db))
await _pool.report()
_pool = WindowPool(task_store.TaskStore(task_store.connect()), None, BROWSER_SLOTS, Journal(db), on_lost=_stop_self)
worker_id = await _pool.register()
_background.extend([asyncio.create_task(_heartbeat_loop(_pool)), asyncio.create_task(_cleanup_loop(_pool))])
log.info("worker %s: %d browser slots, queues %s, %s", WORKER_ID, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(WORKER_ID))
log.info("worker %s: %d browser slots, queues %s, %s", worker_id, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(worker_id))
return worker_id
async def _stop() -> None:
@@ -80,7 +91,7 @@ async def _stop() -> None:
task.cancel()
_background.clear()
await _pool.close_all()
await _pool.store.remove_worker(WORKER_ID)
await _pool.unregister()
if _pool.journal.db:
await _pool.journal.db.close()
@@ -90,14 +101,14 @@ class Lifecycle(Middleware):
добавлен в брокер раньше), а окна закрываются до его остановки."""
def before_worker_boot(self, broker, worker):
_run(_start())
# персональная очередь объявляется до запуска потоков воркера: dramatiq подпишется и на неё
broker.declare_queue(personal_queue(_run(_start())))
def before_worker_shutdown(self, broker, worker):
_run(_stop())
broker.add_middleware(Lifecycle())
broker.declare_queue(personal_queue(WORKER_ID))
@dramatiq.actor(actor_name=RUN_TASK, queue_name=COMMON_QUEUE, max_retries=0, time_limit=TASK_TIME_LIMIT_MS)
+36 -3
View File
@@ -18,11 +18,13 @@ import base64
import logging
import os
import shutil
import socket
import time
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, Callable
from urllib.parse import urlsplit
from patchright.async_api import Page, ProxySettings
@@ -162,10 +164,18 @@ def build_result(payload: TaskRequest, result: dict) -> dict:
class WindowPool:
def __init__(self, store: TaskStore, worker_id: str, slots: int, journal: Journal | None = None):
def __init__(
self, store: TaskStore, worker_id: str | None, slots: int, journal: Journal | None = None,
on_lost: Callable[[], None] | None = None,
):
"""worker_id None — имя берётся в Redis при register() (#110).
on_lost — вызывается, если имя занял другой процесс: такому воркеру надо остановиться."""
self.store = store
self.journal = journal or Journal(None)
self.worker_id = worker_id
self.instance = uuid.uuid4().hex
self.on_lost = on_lost
self.lost = False
self.slots = slots
self.used = 0
self.windows: dict[str, Window] = {}
@@ -178,8 +188,19 @@ class WindowPool:
"free_slots": self.slots - self.used,
"windows": len(self.windows),
"running": len(self.running),
"instance": self.instance,
"host": socket.gethostname(),
"pid": os.getpid(),
}
async def register(self) -> str:
"""Занять свободное имя worker-N: оно же — персональная очередь воркера."""
self.worker_id = await self.store.acquire_worker_name(self.info())
return self.worker_id
async def unregister(self) -> None:
await self.store.remove_worker(self.worker_id, self.instance)
@asynccontextmanager
async def _session_lock(self, session_id: str):
entry = self._locks.setdefault(session_id, _SessionLock())
@@ -194,10 +215,20 @@ class WindowPool:
async def report(self) -> None:
"""heartbeat со слотами сразу после их изменения: по нему api решает, отвечать ли 503 на /v1/solve."""
if self.lost:
return
try:
await self.store.heartbeat(self.worker_id, self.info())
owned = await self.store.heartbeat(self.worker_id, self.info())
except Exception:
log.warning("heartbeat failed", exc_info=True)
return
if not owned:
# Redis был недоступен дольше HEARTBEAT_TTL, и имя занял другой процесс: задачи его очереди
# теперь не наши — останавливаемся, после перезапуска возьмём свободное имя
self.lost = True
log.error("worker name %s is taken by another process, stopping", self.worker_id)
if self.on_lost:
self.on_lost()
def _busy(self, session_id: str) -> bool:
entry = self._locks.get(session_id)
@@ -318,6 +349,8 @@ class WindowPool:
touch_profile(user_data_dir)
# отпечаток задаётся профилю один раз (см. api/profile.py)
profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir))
# без менеджера паролей, перевода и автозаполнения (#111)
profile.apply_chrome_prefs(user_data_dir)
proxy = parse_proxy(payload.proxy) if payload.proxy else None
# geo — по exit-IP прокси (см. api/geoip.py); без прокси оставляем настройки браузера
+63
View File
@@ -221,3 +221,66 @@ def test_typing_like_a_human(client):
assert data["status"] == "ok", data
assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2"
assert data["actions"][1]["duration_ms"] > 12 * 50 # посимвольно, с паузами
# капча-картинка (#109): картинка рисуется на canvas при загрузке, поэтому каждый новый запрос src дал бы другой код
CAPTCHA_PAGE = b"""<!doctype html><body>
<form id="signup"><img class="captcha"><input name="captcha" autocomplete="off"><div id="sent"></div></form>
<script>
const c = document.createElement('canvas'); c.width = 120; c.height = 40;
const x = c.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 120, 40);
x.fillStyle = '#000'; x.font = '24px monospace'; x.fillText('k' + Math.random().toString(36).slice(2, 6), 8, 28);
document.querySelector('img.captcha').src = c.toDataURL();
document.querySelector('input').addEventListener('input', e => document.getElementById('sent').textContent = e.target.value);
</script></body>"""
@pytest.fixture
def captcha_site():
"""Страница с капчей-картинкой на локальном HTTP-сервере."""
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(CAPTCHA_PAGE)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}/signup"
server.shutdown()
def test_captcha_image(client, captcha_site, monkeypatch):
"""Снимок элемента (PNG размером с картинку) уходит в 2captcha, ответ набирается в поле."""
import base64
import struct
from twocaptcha import TwoCaptcha
sent = {}
def fake_solve(self, **kwargs):
sent.update(kwargs)
return {"captchaId": "1", "code": "k42z"}
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
data = solve(client, url=captcha_site, captcha="KEY", actions=[
{"type": "captcha", "selector": "img.captcha", "value": "image", "target": "input[name=captcha]", "max_len": 6},
{"type": "inner_text", "selector": "#sent"},
])
assert data["status"] == "ok", data
assert [a.get("result") for a in data["actions"]] == ["k42z", "k42z"]
png = base64.b64decode(sent["body"])
assert png.startswith(b"\x89PNG")
width, height = struct.unpack(">II", png[16:24])
assert (width, height) == (120, 40)
assert sent["method"] == "base64" and sent["maxLen"] == 6
@@ -0,0 +1,7 @@
{
"dev": {
"api_key": "rbx_...",
"proxy": "http://user:pass@host:port",
"captcha_key": "<ключ 2captcha>"
}
}
+59 -8
View File
@@ -1,4 +1,6 @@
# Test your FastAPI endpoints
# Секреты (api_key, proxy, captcha_key) — в tests/http-client.private.env.json (не в git, шаблон —
# http-client.private.env.example.json); окружение dev выбирается в PyCharm над запросом.
GET http://127.0.0.1:8000/
Accept: application/json
@@ -13,7 +15,7 @@ Accept: application/json
POST http://127.0.0.1:8000/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
{
@@ -36,7 +38,7 @@ X-API-Key: <key>
POST http://127.0.0.1:8000/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
@@ -53,7 +55,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
POST http://127.0.0.1:8000/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
@@ -72,7 +74,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
POST http://127.0.0.1:8000/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
@@ -91,7 +93,7 @@ Cookie: session_id=2f2100be-6ce7-40d5-bae5-61424b2504c8; Path=/; SameSite=lax
POST http://127.0.0.1:8000/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
{
@@ -112,11 +114,11 @@ X-API-Key: <key>
POST http://localhost:8002/v2/tasks
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
{
"url": "https://freelancehunt.com/",
"proxy": "http://BQLo3X:poqAWX@168.81.43.154:8000",
"proxy": "{{proxy}}",
"actions": [
{
"type": "inner_text",
@@ -132,6 +134,55 @@ X-API-Key: <key>
GET http://localhost:8002/v2/tasks/4d8105f8-93a8-43ee-aa6e-db917451363b
Accept: application/json
Content-Type: application/json
X-API-Key: <key>
X-API-Key: {{api_key}}
###
# @timeout 600
POST http://localhost:8010/v1/solve
Accept: application/json
Content-Type: application/json
X-API-Key: {{api_key}}
{
"url": "https://lessons.zennolab.com/captchas/recaptcha/v2_nosubmit.php?level=low",
"proxy": "{{proxy}}",
"actions": [
{
"type": "inner_text",
"selector": "//div[@id='main']/div/h2"
}
],
"captcha": "{{captcha_key}}",
"captcha_type": "recaptcha",
"captcha_success_locator": "//div[@id='main']"
}
###
POST https://app.robotex.sokolab.xyz/v2/tasks
Accept: application/json
Content-Type: application/json
X-API-Key: {{api_key}}
{
"url": "https://lessons.zennolab.com/captchas/recaptcha/v2_nosubmit.php?level=low",
"proxy": "{{proxy}}",
"actions": [
{
"type": "inner_text",
"selector": "//div[@id='main']/div/h2"
}
],
"captcha": "{{captcha_key}}",
"captcha_type": "recaptcha",
"captcha_success_locator": "//div[@id='main']"
}
###
GET https://app.robotex.sokolab.xyz/v2/tasks/f75d4a97-afad-4caf-b8eb-595a3f26f6f0
Accept: application/json
Content-Type: application/json
X-API-Key: {{api_key}}
###
+92
View File
@@ -1,4 +1,6 @@
"""api.actions.execute на поддельной странице: порядок, остановка на ошибке, антибот, длительность."""
import base64
import pytest
from antibot.orchestrator import ChallengeOutcome
@@ -113,3 +115,93 @@ async def test_pass_challenge_action(challenge):
result = await actions.execute(FakePage(), acts({"type": "pass_challenge"}), antibot_name="cloudflare")
assert result[0]["ok"] and result[0]["result"] == "passed"
assert challenge["calls"] == 2
class FakeImage:
"""Locator картинки капчи: скриншот элемента — заранее заданные байты."""
def __init__(self, png):
self.png = png
self.waited = False
@property
def first(self):
return self
async def wait_for(self):
self.waited = True
async def evaluate(self, script):
return True
async def screenshot(self, type):
return self.png
class ImagePage(FakePage):
def __init__(self, image):
super().__init__()
self.image = image
self.locators = []
def locator(self, selector):
self.locators.append(selector)
return self.image
async def test_captcha_image(challenge, monkeypatch):
"""Снимок элемента уходит в 2captcha base64 с подсказками, ответ набирается в target и попадает в result."""
from captcha import image as image_mod
from twocaptcha import TwoCaptcha
sent, typed = {}, {}
def fake_solve(self, **kwargs):
sent.update(kwargs)
return {"captchaId": "1", "code": "x7Kp2"}
async def fake_typing(page, locator, text):
typed[locator] = text
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
monkeypatch.setattr(image_mod, "human_typing", fake_typing)
page = ImagePage(FakeImage(b"\x89PNG-captcha"))
result = await actions.execute(page, acts(
{"type": "captcha", "selector": "#popup-signup img.captcha", "value": "image",
"target": "#popup-signup input[name=\"captcha\"]", "min_len": 5, "numeric": 4, "case_sensitive": True},
), captcha_key="KEY")
assert [(r["ok"], r.get("result")) for r in result] == [(True, "x7Kp2")]
assert page.locators == ["#popup-signup img.captcha"]
assert page.image.waited
assert sent == {
"method": "base64", "body": base64.b64encode(b"\x89PNG-captcha").decode(),
"minLen": 5, "numeric": 4, "caseSensitive": 1,
}
assert typed == {"#popup-signup input[name=\"captcha\"]": "x7Kp2"}
async def test_captcha_image_unsolved(challenge, monkeypatch):
"""2captcha не решила — действие падает, в target ничего не вводится."""
from captcha import image as image_mod
from twocaptcha import TwoCaptcha
from twocaptcha.solver import ApiException
def fake_solve(self, **kwargs):
raise ApiException("ERROR_CAPTCHA_UNSOLVABLE")
typed = []
async def fake_typing(page, locator, text):
typed.append(text)
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
monkeypatch.setattr(image_mod, "human_typing", fake_typing)
result = await actions.execute(ImagePage(FakeImage(b"png")), acts(
{"type": "captcha", "selector": "img", "value": "image", "target": "#in"},
), captcha_key="KEY")
assert [(r["ok"], r["result"]) for r in result] == [(False, "ERROR_CAPTCHA_UNSOLVABLE")]
assert typed == []
+5 -3
View File
@@ -51,15 +51,17 @@ def submit(actors, server, queue, idle=0):
@pytest.mark.parametrize("queue", ["common", "personal"])
def test_task_executed_from_queue(worker, queue):
actors, server = worker
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue(actors.WORKER_ID))
# имя воркера — первое свободное в Redis (#110), персональная очередь объявлена до запуска потоков
assert actors._pool.worker_id == "worker-1"
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue("worker-1"))
task = redis.hgetall("robotex:task:t1")
assert task["status"] == "done", task
assert '"status": "ok"' in task["result"]
assert redis.exists(f"robotex:worker:{actors.WORKER_ID}")
assert redis.exists("robotex:worker:worker-1")
def test_window_held_until_shutdown(worker):
actors, server = worker
redis = submit(actors, server, COMMON_QUEUE, idle=5)
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == actors.WORKER_ID
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == "worker-1"
assert not FakeSession.instances[0].closed
+3
View File
@@ -38,6 +38,9 @@ def test_action_builders():
assert Action.pass_challenge().to_dict() == {"type": "pass_challenge"}
with pytest.raises(ValueError):
Action.captcha("#c", "funcaptcha")
assert Action.captcha_image("img", "#in", numeric=1, case_sensitive=False).to_dict() == {
"type": "captcha", "selector": "img", "value": "image", "target": "#in", "numeric": 1, "case_sensitive": False,
}
def test_settings_from_env(monkeypatch):
+36
View File
@@ -69,3 +69,39 @@ def test_profiles_get_different_canvas_seeds(tmp_path):
(tmp_path / str(i)).mkdir()
seeds.add(profile.load_or_create(tmp_path / str(i))["fingerprint"]["canvas_seed"])
assert len(seeds) == 20
def test_chrome_prefs_for_new_profile(tmp_path):
profile.apply_chrome_prefs(tmp_path)
prefs = json.loads((tmp_path / "Default" / "Preferences").read_text())
assert prefs["credentials_enable_service"] is False
assert prefs["profile"]["password_manager_enabled"] is False
assert prefs["translate"]["enabled"] is False
assert prefs["autofill"]["credit_card_enabled"] is False
def test_chrome_prefs_keep_existing_profile_settings(tmp_path):
"""Существующий профиль: prefs дописываются, остальные настройки Chrome остаются."""
path = tmp_path / "Default" / "Preferences"
path.parent.mkdir()
path.write_text(json.dumps({
"profile": {"name": "Person 1", "password_manager_enabled": True},
"translate": {"enabled": True},
"autofill": {"autofill_ai": {"opt_in_status": {}}},
"intl": {"accept_languages": "ru-RU,ru"},
}))
profile.apply_chrome_prefs(tmp_path)
prefs = json.loads(path.read_text())
assert prefs["profile"] == {"name": "Person 1", "password_manager_enabled": False, "password_manager_leak_detection": False}
assert prefs["translate"]["enabled"] is False
assert prefs["autofill"]["autofill_ai"] == {"opt_in_status": {}}
assert prefs["intl"] == {"accept_languages": "ru-RU,ru"}
assert prefs["credentials_enable_service"] is False
def test_chrome_prefs_replace_broken_preferences(tmp_path):
path = tmp_path / "Default" / "Preferences"
path.parent.mkdir()
path.write_text("{broken")
profile.apply_chrome_prefs(tmp_path)
assert json.loads(path.read_text())["translate"] == {"enabled": False}
+92
View File
@@ -0,0 +1,92 @@
"""Два процесса воркера на одном брокере и Redis (#110): у каждого своё имя и своя персональная очередь,
задача без url в открытое окно всегда попадает к воркеру, который держит это окно."""
import itertools
import pytest
import main
from db.journal import Journal
from tasks.broker import COMMON_QUEUE, RUN_TASK, personal_queue
from worker import windows
@pytest.fixture
def two_workers(env, monkeypatch):
"""Два пула вместо пула харнесса; общую очередь разбирают по очереди, как два consumer'а RabbitMQ."""
env.redis.delete("robotex:worker:w1")
pools = [windows.WindowPool(env.store, None, slots=2, journal=Journal(env.db)) for _ in range(2)]
for pool in pools:
env.call(pool.register)
turn = itertools.cycle(pools)
delivered = []
async def dispatch(message):
if message.queue_name == COMMON_QUEUE:
pool = next(turn)
else:
pool = next(p for p in pools if personal_queue(p.worker_id) == message.queue_name)
delivered.append((message.args[1], pool.worker_id))
if message.actor_name == RUN_TASK:
await pool.handle(*message.args)
else:
await pool.close(*message.args)
await pool.report()
monkeypatch.setattr(main, "dispatch", dispatch)
yield pools, delivered
for pool in pools:
env.call(pool.close_all)
def run(env, session_id=None, **body):
env.client.cookies.clear()
if session_id:
env.client.cookies.set("session_id", session_id)
resp = env.client.post("/v2/tasks", json={"actions": [], "browser_idle_timeout": 5, **body})
assert resp.status_code == 202, resp.text
task = env.client.get(f"/v2/tasks/{resp.json()['task_id']}").json()
return resp.cookies.get("session_id") or session_id, task
def test_workers_get_distinct_names(env, two_workers):
pools, _ = two_workers
assert [p.worker_id for p in pools] == ["worker-1", "worker-2"]
workers = env.client.get("/health").json()["workers"]
assert [w["worker_id"] for w in workers] == ["worker-1", "worker-2"]
assert len({w["instance"] for w in workers}) == 2
def test_task_without_url_goes_to_window_owner(env, two_workers):
pools, delivered = two_workers
# окна двух сессий открываются на разных воркерах
sid_a, task = run(env, url="https://example.com/a")
assert task["result"]["status"] == "ok"
sid_b, task = run(env, url="https://example.com/b")
assert task["result"]["status"] == "ok"
owner = {sid: wid for sid, wid in delivered}
assert sorted(owner.values()) == ["worker-1", "worker-2"]
# следующие задачи без url — всегда в окно своей сессии
for _ in range(3):
for sid in (sid_a, sid_b):
_, task = run(env, sid)
assert task["status"] == "done" and task["result"]["status"] == "ok", task
assert delivered[-1] == (sid, owner[sid])
assert all(len(p.windows) == 1 for p in pools)
def test_worker_stops_when_name_is_taken(env, two_workers):
"""Имя истекло (Redis был недоступен) и досталось другому процессу — старый владелец останавливается."""
pools, _ = two_workers
stopped = []
pools[0].on_lost = lambda: stopped.append(True)
env.redis.delete("robotex:worker:worker-1")
newcomer = windows.WindowPool(env.store, None, slots=1)
assert env.call(newcomer.register) == "worker-1"
env.call(pools[0].report)
env.call(pools[0].report)
assert stopped == [True] and pools[0].lost
# при остановке старый владелец не снимает чужой heartbeat
env.call(pools[0].unregister)
assert env.client.get("/health").json()["workers"][0]["instance"] == newcomer.instance
+10
View File
@@ -21,6 +21,13 @@ def solve(**kwargs) -> SolveRequest:
({"type": "wait_for", "selector": "a", "timeout": 0}, "between 1 and 7200"),
({"type": "wait_for", "selector": "a", "timeout": 7201}, "between 1 and 7200"),
({"type": "captcha", "selector": "#c", "value": "datadome"}, "captcha value must be one of"),
({"type": "captcha", "selector": "img", "value": "image"}, "requires: target"),
({"type": "captcha", "selector": "#c", "value": "hcaptcha", "target": "#in"}, "only for action 'captcha'"),
({"type": "typing", "selector": "#q", "value": "x", "numeric": 1}, "only for action 'captcha'"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "numeric": 5}, "numeric"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "max_len": 21}, "max_len"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "min_len": 6, "max_len": 4},
"min_len must not exceed max_len"),
])
def test_invalid_action(action, error):
with pytest.raises(ValidationError, match=error):
@@ -35,6 +42,9 @@ def test_invalid_action(action, error):
{"type": "delay", "value": "0"},
{"type": "pass_challenge"},
{"type": "captcha", "selector": "#c", "value": "hcaptcha"},
{"type": "captcha", "selector": "img", "value": "image", "target": "#in"},
{"type": "captcha", "selector": "img", "value": "image", "target": "#in",
"min_len": 4, "max_len": 6, "numeric": 1, "lang": "en", "case_sensitive": True},
{"type": "inner_html", "selector": "h1"},
])
def test_valid_action(action):
+29
View File
@@ -72,3 +72,32 @@ async def test_live_window_requires_live_worker(store):
await store.heartbeat("w1", {"browser_slots": 1, "free_slots": 0})
assert await store.live_window("s") == {"worker_id": "w1", "idle_timeout": "5", "client_id": "7"}
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
async def test_worker_names_are_unique_and_reused(store):
"""Имена worker-N: занятое не выдаётся, освободившееся — переиспользуется (#110)."""
assert await store.acquire_worker_name({"instance": "a"}) == "worker-1"
assert await store.acquire_worker_name({"instance": "b"}) == "worker-2"
await store.remove_worker("worker-1", "a")
assert await store.acquire_worker_name({"instance": "c"}) == "worker-1"
assert await store.redis.ttl("robotex:worker:worker-1") == task_store.HEARTBEAT_TTL
async def test_heartbeat_of_taken_name_is_refused(store):
"""Имя истекло и его занял другой процесс: старый владелец его не продлит и не удалит."""
assert await store.acquire_worker_name({"instance": "old"}) == "worker-1"
assert await store.heartbeat("worker-1", {"instance": "old", "windows": 1})
await store.redis.delete("robotex:worker:worker-1") # TTL истёк, пока Redis был недоступен
assert await store.acquire_worker_name({"instance": "new"}) == "worker-1"
assert not await store.heartbeat("worker-1", {"instance": "old"})
await store.remove_worker("worker-1", "old")
assert (await store.workers())[0]["instance"] == "new"
async def test_expired_own_name_is_renewed(store):
"""Имя истекло, но никто его не занял — heartbeat просто продлевает его."""
assert await store.acquire_worker_name({"instance": "a"}) == "worker-1"
await store.redis.delete("robotex:worker:worker-1")
assert await store.heartbeat("worker-1", {"instance": "a"})
assert await store.worker_alive("worker-1")