Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ddf48a13 | ||
|
|
232385f8d8 | ||
|
|
677fea0090 | ||
|
|
5ed2cfbb3d | ||
|
|
502b6e1003 | ||
|
|
1f7d60563d | ||
|
|
9d616e1f4f | ||
|
|
0e10fb671d | ||
|
|
4d90dc4a21 | ||
|
|
fdee12e2d8 |
No files matched your search
+1
-1
@@ -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
|
||||
@@ -9,3 +9,4 @@ build/
|
||||
client/dist/
|
||||
client/build/
|
||||
client/src/*.egg-info/
|
||||
http-client.private.env.json
|
||||
@@ -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' \
|
||||
'```' \
|
||||
'' \
|
||||
|
||||
@@ -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
@@ -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]
|
||||
```
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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); без прокси оставляем настройки браузера
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
###
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user