diff --git a/README.md b/README.md index e18a854..ad9721b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Not implemented yet, though planned for v1: - API-key auth (`X-API-Key` is accepted but **not checked**) — [#4](https://redmine.sokolab.xyz/issues/4) - persisting request outcomes (success rate) — [#17](https://redmine.sokolab.xyz/issues/17) -- cleanup of old browser profiles — [#20](https://redmine.sokolab.xyz/issues/20) ## Architecture @@ -41,7 +40,7 @@ Client → Caddy (TLS) → api (FastAPI) ──task──▶ RabbitMQ ──▶ reCAPTCHA, hCaptcha) are solved via the client's 2captcha key. - **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and timezone are derived from the proxy's exit IP. -- **Sessions**: `session_id` cookie → persistent Chrome profile on disk. A fingerprint (screen, DPR) is generated on +- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR) is generated on the first request of a session; cookies passed in a request replace the profile's cookies. - **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response. diff --git a/docs/Deployment.md b/docs/Deployment.md index 97f6dd1..792a778 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -52,7 +52,9 @@ API доступно на `http://localhost:8000`. - `extra/user_data_dir/` — профили браузера (cookies, local storage, fingerprint) по `session_id`; - `extra/geoip_cache.json` — кэш геолокации прокси. -Профили со временем накапливаются — чистить вручную: +Воркер удаляет профили, которыми не пользовались `PROFILE_TTL_DAYS` дней (по умолчанию 7): при старте и затем раз +в час. Профиль с открытым окном не трогается. Клиент, вернувшийся позже, получает новый профиль и заново проходит +антибот. Удалить все профили вручную: ```bash docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*' @@ -78,6 +80,7 @@ docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*' | `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` | | `NO_SLOTS_RETRY_AFTER` | `5` | api: значение `Retry-After` (с) в ответе `503` | | `RABBITMQ_URL` | `amqp://guest:guest@localhost:5672/` | api, worker; в compose задаётся сам | diff --git a/env.example b/env.example index 4689048..f0136d7 100644 --- a/env.example +++ b/env.example @@ -10,6 +10,8 @@ BROWSER_SLOTS=4 RABBITMQ_PASSWORD=robotex # сколько часов хранится результат задачи в Redis RESULT_TTL_HOURS=24 +# через сколько дней без использования удаляется профиль браузера +PROFILE_TTL_DAYS=7 # сколько /v1/solve ждёт, пока воркер возьмёт задачу, прежде чем ответить 503 V1_QUEUE_WAIT=30 NO_SLOTS_RETRY_AFTER=5 diff --git a/src/worker/actors.py b/src/worker/actors.py index 779e170..6b5c218 100644 --- a/src/worker/actors.py +++ b/src/worker/actors.py @@ -31,10 +31,11 @@ BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4")) # через сколько задача без свободного слота снова попадёт в очередь NO_SLOT_DELAY_MS = 2000 HEARTBEAT_INTERVAL = 10 +PROFILE_CLEANUP_INTERVAL = 3600 TASK_TIME_LIMIT_MS = task_store.RUNNING_TTL * 1000 _pool: WindowPool | None = None -_background: asyncio.Task | None = None +_background: list[asyncio.Task] = [] def _run(coro): @@ -52,17 +53,28 @@ async def _heartbeat_loop(pool: WindowPool) -> None: await asyncio.sleep(HEARTBEAT_INTERVAL) +async def _cleanup_loop(pool: WindowPool) -> None: + """Удаление старых профилей: сразу при старте и затем раз в час.""" + while True: + try: + await pool.cleanup_profiles() + except Exception: + log.warning("profile cleanup failed", exc_info=True) + await asyncio.sleep(PROFILE_CLEANUP_INTERVAL) + + async def _start() -> None: - global _pool, _background + global _pool _pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS) await _pool.report() - _background = asyncio.create_task(_heartbeat_loop(_pool)) + _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)) async def _stop() -> None: - if _background: - _background.cancel() + for task in _background: + task.cancel() + _background.clear() await _pool.close_all() await _pool.store.remove_worker(WORKER_ID) diff --git a/src/worker/windows.py b/src/worker/windows.py index eca02e8..e4af19d 100644 --- a/src/worker/windows.py +++ b/src/worker/windows.py @@ -16,6 +16,8 @@ Redis: TTL ключа `session:`, каждая задача в окно ст import asyncio import base64 import logging +import os +import shutil import time from contextlib import asynccontextmanager, suppress from dataclasses import dataclass, field @@ -30,11 +32,16 @@ from api.geoip import resolve_geo from api.logs import safe_request_dump, safe_response_dump from api.schemas import TaskRequest from engine import AsyncStealthySession +from tasks.sessions import parse_session_id from tasks.store import RUNNING_TTL, Status, TaskStore log = logging.getLogger(__name__) USER_DATA_ROOT = (Path(__file__).parent / "../../extra/user_data_dir").resolve() +# профиль, которым не пользовались столько дней, удаляется (#20) +PROFILE_TTL_DAYS = float(os.getenv("PROFILE_TTL_DAYS", "7")) +# время последнего использования профиля — mtime этого файла +LAST_USED_FILE = ".robotex_last_used" class Outcome: @@ -82,6 +89,18 @@ def session_dir(session_id: str) -> Path: return user_data_dir +def touch_profile(user_data_dir: Path) -> None: + (user_data_dir / LAST_USED_FILE).touch() + + +def profile_last_used(user_data_dir: Path) -> float: + try: + return (user_data_dir / LAST_USED_FILE).stat().st_mtime + except OSError: + # профиль из версии без отметки — по времени изменения каталога + return user_data_dir.stat().st_mtime + + def make_page_action(payload: TaskRequest): """page_action для AsyncStealthySession.fetch()/run(): выполняет очередь действий (вместе с прохождением антибота, api.actions.execute) и достаёт со страницы всё, что нужно для ответа. @@ -275,6 +294,7 @@ class WindowPool: async def _open(self, session_id: str, payload: TaskRequest, nav_kwargs: dict): user_data_dir = session_dir(session_id) user_data_dir.mkdir(parents=True, exist_ok=True) + touch_profile(user_data_dir) # отпечаток задаётся профилю один раз (см. api/profile.py) profile_kwargs = profile.browser_kwargs(profile.load_or_create(user_data_dir)) @@ -312,6 +332,9 @@ class WindowPool: async def _close(self, window: Window) -> None: """Закрыть окно и освободить его слот.""" + if window.engine is not None: + with suppress(OSError): + touch_profile(session_dir(window.session_id)) await self._close_engine(window) if self.windows.get(window.session_id) is window: del self.windows[window.session_id] @@ -346,3 +369,22 @@ class WindowPool: await self._close(window) with suppress(Exception): await self.store.delete_session(session_id) + + async def cleanup_profiles(self, ttl_days: float = PROFILE_TTL_DAYS) -> list[str]: + """Удалить профили, которыми не пользовались ttl_days дней (#20). Профиль с открытым окном — + на этом воркере или на другом (ключ session: в Redis) — не трогается.""" + if not USER_DATA_ROOT.is_dir(): + return [] + deadline = time.time() - ttl_days * 86400 + removed = [] + for user_data_dir in USER_DATA_ROOT.iterdir(): + session_id = parse_session_id(user_data_dir.name) + if not session_id or not user_data_dir.is_dir() or profile_last_used(user_data_dir) > deadline: + continue + if session_id in self.windows or self._busy(session_id) or await self.store.get_session(session_id): + continue + await asyncio.to_thread(shutil.rmtree, user_data_dir, True) + removed.append(session_id) + if removed: + log.info("removed %d browser profiles unused for %s days", len(removed), ttl_days) + return removed diff --git a/tests/unit/test_v2.py b/tests/unit/test_v2.py index 856c497..abe782a 100644 --- a/tests/unit/test_v2.py +++ b/tests/unit/test_v2.py @@ -178,3 +178,34 @@ def test_dead_worker_window_goes_to_common_queue(env): env.auto = False create(env, url="https://example.com") assert env.messages[-1].queue_name == COMMON_QUEUE + + +def test_old_profiles_cleaned_up(env, tmp_path): + """#20: профили старше PROFILE_TTL_DAYS удаляются, кроме тех, где открыто окно.""" + import os + import time + + from worker.windows import LAST_USED_FILE + + create(env, url="https://example.com", browser_idle_timeout=5) + held = env.client.cookies["session_id"] + elsewhere, old, fresh = (str(uuid.uuid4()) for _ in range(3)) + env.redis.hset(session_key(elsewhere), mapping={"worker_id": "w2", "idle_timeout": 5}) + + long_ago = time.time() - 8 * 86400 + for sid in (held, elsewhere, old, fresh, "not-a-session"): + (tmp_path / sid).mkdir(exist_ok=True) + marker = tmp_path / sid / LAST_USED_FILE + marker.touch() + if sid != fresh: + os.utime(marker, (long_ago, long_ago)) + + assert env.call(env.pool.cleanup_profiles) == [old] + assert sorted(p.name for p in tmp_path.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"]) + + +def test_profile_marked_as_used(env, tmp_path): + from worker.windows import LAST_USED_FILE + + create(env, url="https://example.com") + assert (tmp_path / env.client.cookies["session_id"] / LAST_USED_FILE).exists()