Compare commits
45 Commits
78265d92ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d08a67141a | |||
| ad894eca2f | |||
| dd49d8c974 | |||
| 40ff56490d | |||
| ddf37f013d | |||
| 2e507cf5fa | |||
| 5001a8f2e8 | |||
| e222ffdcc4 | |||
| 776de685d0 | |||
| 24402b3aa9 | |||
| b035312e83 | |||
| 4b805a64a5 | |||
| 5a9087b7fc | |||
| 99cbbf743a | |||
| bbd0509d8b | |||
| acd613b468 | |||
| 56fa29c91b | |||
| e7410c1ac2 | |||
| 604d5e25c1 | |||
| c859028627 | |||
| 040e57c3c0 | |||
| 47fa0229b3 | |||
| 82cf4f1f82 | |||
| 1ce57c3ea2 | |||
| 0bbd4ba840 | |||
| f60de9312a | |||
| 5bc7acd114 | |||
| e8d0108f9a | |||
| a436b72bec | |||
| 240104a3f2 | |||
| 8639d641be | |||
| 061b03eee5 | |||
| 71790cf0ad | |||
| 9f986255b3 | |||
| e6ee587bf0 | |||
| 66c8c19e0c | |||
| 601b8ec69e | |||
| 823acdd128 | |||
| 3982b03f5c | |||
| 4462990d6f | |||
| 3f180e88ed | |||
| 983b5279a2 | |||
| a5a9164a35 | |||
| 943dc190a3 | |||
| 80a892478f |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m venv .venv)",
|
||||||
|
"Bash(.venv/bin/pip install *)",
|
||||||
|
"Read(//home/sana/.config/uv/**)",
|
||||||
|
"Read(//home/sana/.pip/**)",
|
||||||
|
"Bash(make admin *)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:8000/)",
|
||||||
|
"Bash(kill %1)",
|
||||||
|
"Bash(uv run *)",
|
||||||
|
"Bash(curl -sv http://127.0.0.1:8000/)",
|
||||||
|
"Bash(no_proxy=\"127.0.0.1\" curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:8000/)",
|
||||||
|
"Bash(echo \"exit: $?\")",
|
||||||
|
"Bash(google-chrome --version)",
|
||||||
|
"Bash(google-chrome-stable --version)",
|
||||||
|
"Bash(chromium --version)",
|
||||||
|
"Bash(make patch-driver *)",
|
||||||
|
"Bash(rtk ls *)",
|
||||||
|
"Bash(./drivers/chromedriver *)",
|
||||||
|
"Bash(rtk grep *)",
|
||||||
|
"Bash(rtk git *)",
|
||||||
|
"Bash(python *)",
|
||||||
|
"Bash(rtk read *)",
|
||||||
|
"Bash(uv add *)",
|
||||||
|
"Bash(wait)",
|
||||||
|
"Bash(fuser -k 8000/tcp)",
|
||||||
|
"Bash(fuser -k 9000/tcp)",
|
||||||
|
"Bash(rtk find *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.claude
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
drivers
|
||||||
|
*.db
|
||||||
|
*.swp
|
||||||
@@ -18,6 +18,12 @@ HEADLESS=true
|
|||||||
# CHROME_BINARY=/usr/bin/google-chrome-stable # optional: explicit Chrome binary path
|
# CHROME_BINARY=/usr/bin/google-chrome-stable # optional: explicit Chrome binary path
|
||||||
# CHROMEDRIVER_PATH=drivers/chromedriver # pre-patched driver (run: make patch-driver)
|
# CHROMEDRIVER_PATH=drivers/chromedriver # pre-patched driver (run: make patch-driver)
|
||||||
|
|
||||||
|
# Worker browser-session recordings (Docker Compose enables these by default)
|
||||||
|
RECORD_SESSIONS=false
|
||||||
|
RECORDING_FPS=12
|
||||||
|
RECORDING_MAX_FILES=3
|
||||||
|
RECORDINGS_DIR=recordings
|
||||||
|
|
||||||
# Scenario 1 — comma-separated phone numbers
|
# Scenario 1 — comma-separated phone numbers
|
||||||
PHONE_NUMBERS=+989100000001,+989100000002,+989100000003
|
PHONE_NUMBERS=+989100000001,+989100000002,+989100000003
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git push *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$version": 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: playwright-base-docker
|
||||||
|
description: Use mcr.microsoft.com/playwright as a pre-baked Chrome base image for Dockerized crawler/automation projects
|
||||||
|
source: auto-skill
|
||||||
|
extracted_at: '2026-08-04T18:21:03.395Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Dockerizing headless Chrome automation requires Chromium, matching ChromeDriver, Xvfb, ffmpeg, and browser patching — which bloats the image and adds build steps.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Use `mcr.microsoft.com/playwright:<version>-noble` (or similar variant) as the base image for worker/crawler services. Playwright ships with Chromium, ChromeDriver, and common browser automation dependencies pre-installed and pre-patched.
|
||||||
|
|
||||||
|
### Key steps
|
||||||
|
|
||||||
|
1. **Define multi-stage Dockerfile with separate targets**
|
||||||
|
- `admin` target: lightweight `python:3.11-slim` (no Chrome needed for FastAPI)
|
||||||
|
- `worker` target: `mcr.microsoft.com/playwright:v1.62.0-noble` (Chrome + deps included)
|
||||||
|
|
||||||
|
2. **Do NOT copy `.venv` from a different Python version builder stage**
|
||||||
|
- The Playwright Noble image ships **Python 3.12** while `python:3.11-slim` ships 3.11.
|
||||||
|
- Copying a 3.11-built venv into the image produces **broken shebangs** — `exec /app/.venv/bin/rq: no such file or directory` (the binaries exist but their `#!` line points to a non-existent Python 3.11).
|
||||||
|
- **Fix**: install `uv` inside the worker stage and run `uv sync` natively so the venv targets the image's Python.
|
||||||
|
|
||||||
|
3. **Installing `uv` in the Playwright image**
|
||||||
|
- Playwright ships Python but **not** `pip` or `uv` in PATH.
|
||||||
|
- **Working approach**:
|
||||||
|
```dockerfile
|
||||||
|
RUN python -m ensurepip --upgrade \
|
||||||
|
&& python -m pip install --no-cache-dir --break-system-packages uv==0.11.31 \
|
||||||
|
&& uv sync --frozen --no-dev --no-install-project
|
||||||
|
```
|
||||||
|
- `ensurepip` bootstraps pip in the same shell, then `python -m pip` installs the `uv` CLI into `/usr/local/bin/uv` (already in PATH).
|
||||||
|
- Do **NOT** try to set `ENV PATH` in a separate layer before calling `uv` — the install script doesn't modify PATH, and the binary won't be found.
|
||||||
|
- Avoid `curl -LsSf https://astral.sh/uv/install.sh | sh` in a RUN followed by `uv` in the next — the install script is a one-shot that doesn't persist PATH.
|
||||||
|
|
||||||
|
4. **Set environment variables** for Chrome location and headless mode:
|
||||||
|
```dockerfile
|
||||||
|
ENV CHROME_BINARY=/usr/bin/chromium \
|
||||||
|
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
|
||||||
|
HEADLESS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Skip `patch_driver.py`** — Playwright's Chromium is already pre-patched with undetected-chromedriver-friendly modifications.
|
||||||
|
|
||||||
|
6. **Update docker-compose.yml** with explicit build targets:
|
||||||
|
```yaml
|
||||||
|
admin:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: admin
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common pitfalls
|
||||||
|
|
||||||
|
- **`//` in `FROM` lines doesn't work**: `FROM oci.reg.darano.ir//mcr.microsoft.com/playwright:latest AS worker` will fail with `invalid reference format`. The Docker parser confuses `//` with the `AS` syntax. If your registry requires `//` for internal routing, configure it at the **Docker daemon level** in `/etc/docker/daemon.json` under `registry-mirrors`, not in the Dockerfile.
|
||||||
|
- **Don't copy venv from python:3.11-slim to Playwright**: Python version mismatch → broken shebangs. Always build the venv natively in the target stage.
|
||||||
|
- **Don't rely on `pip` being available**: Playwright ships Python without pip. Use `python -m ensurepip` first.
|
||||||
|
|
||||||
|
## What you still need
|
||||||
|
|
||||||
|
- **ffmpeg** and **Xvfb**: The Playwright image includes these for session recording. If you need them for headless mode only (no recording), they are optional.
|
||||||
|
- **security_opt**: Workers still need `cap_add: [SYS_ADMIN]` and `security_opt: [seccomp:unconfined]` for undetected-chromedriver to fully spoof browser fingerprints.
|
||||||
|
- **shm_size**: Keep `shm_size: "2gb"` on worker containers to prevent Chrome OOM crashes.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `Dockerfile` — split into `admin` and `worker` targets
|
||||||
|
- `docker-compose.yml` — explicit `target` per service, separate image names
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
- **Faster builds** — no Chromium download, no ChromeDriver version matching, no patching script
|
||||||
|
- **Smaller admin image** — admin panel doesn't need any browser dependencies
|
||||||
|
- **Reliable driver matching** — Playwright maintains its own bundled Chromium/ChromeDriver pair, eliminating version mismatches
|
||||||
+84
-36
@@ -1,60 +1,108 @@
|
|||||||
|
# Usage:
|
||||||
|
# make build # Build all Docker images (admin + worker)
|
||||||
|
# docker compose up -d # Start all services
|
||||||
|
#
|
||||||
|
# - admin stage: python:3.11-slim (FastAPI only, no Chrome)
|
||||||
|
# - worker stage: Playwright Noble with Python + Chromium pre-installed
|
||||||
|
# from your local registry mirror.
|
||||||
|
|
||||||
|
# ── Build args ─────────────────────────────────────────────────────────────────
|
||||||
|
ARG BASE_IMAGE=python:3.11-slim
|
||||||
|
|
||||||
# ── Stage 1: dependency resolver ─────────────────────────────────────────────
|
# ── Stage 1: dependency resolver ─────────────────────────────────────────────
|
||||||
FROM python:3.11-slim AS builder
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
# Install uv
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy only dependency manifests first (cache layer)
|
# python:3.11-slim has neither curl nor pip. Bootstrap pip via ensurepip,
|
||||||
|
# then use it to install uv from the local PyPI mirror.
|
||||||
|
RUN python -m ensurepip --upgrade \
|
||||||
|
&& python -m pip install --break-system-packages \
|
||||||
|
--index-url https://pypi.reg.darano.ir/simple/ \
|
||||||
|
--trusted-host pypi.reg.darano.ir \
|
||||||
|
uv
|
||||||
|
|
||||||
|
ENV PATH="/usr/local/bin:$PATH" \
|
||||||
|
UV_DEFAULT_INDEX="https://pypi.reg.darano.ir"
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock* ./
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
|
||||||
# Install deps into an isolated prefix so we can copy them cleanly
|
|
||||||
RUN uv sync --frozen --no-dev --no-install-project
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
|
# ── Stage 2a: admin runtime (python:3.11-slim, no Chrome needed) ──────────────
|
||||||
FROM python:3.11-slim AS runtime
|
FROM ${BASE_IMAGE} AS admin
|
||||||
|
|
||||||
# ── Chrome + system deps ──────────────────────────────────────────────────────
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
wget gnupg ca-certificates curl unzip \
|
|
||||||
# X11 / rendering libs needed even in headless mode
|
|
||||||
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
||||||
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
|
||||||
libgbm1 libasound2 libpango-1.0-0 libcairo2 libx11-xcb1 \
|
|
||||||
&& wget -qO- https://dl.google.com/linux/linux_signing_key.pub \
|
|
||||||
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
|
|
||||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] \
|
|
||||||
http://dl.google.com/linux/chrome/deb/ stable main" \
|
|
||||||
> /etc/apt/sources.list.d/google-chrome.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends google-chrome-stable \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ── uv + venv from builder ────────────────────────────────────────────────────
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Make the venv's binaries the default Python
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH" \
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
# Tell undetected-chromedriver where Chrome lives
|
UV_DEFAULT_INDEX="https://pypi.reg.darano.ir"
|
||||||
CHROME_BINARY=/usr/bin/google-chrome-stable \
|
|
||||||
# Always run headless inside Docker
|
|
||||||
HEADLESS=true
|
|
||||||
|
|
||||||
# ── Application code ──────────────────────────────────────────────────────────
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
RUN mkdir -p data logs
|
RUN mkdir -p data logs recordings \
|
||||||
|
&& useradd -m -u 1001 seed \
|
||||||
|
&& chown -R seed:seed /app
|
||||||
|
|
||||||
# Non-root user for safety
|
|
||||||
RUN useradd -m -u 1001 seed && chown -R seed:seed /app
|
|
||||||
USER seed
|
USER seed
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["python", "main.py", "admin"]
|
CMD ["python", "main.py", "admin"]
|
||||||
|
|
||||||
|
# ── Stage 2b: worker runtime (Playwright Noble + Python installed) ─────────────
|
||||||
|
# Base: oci.reg.darano.ir/mcr.microsoft.com/playwright:v1.62.0-noble
|
||||||
|
# This image ships: Ubuntu 24.04 + Chromium + Firefox + WebKit + system libs.
|
||||||
|
# We add Python + uv ourselves.
|
||||||
|
|
||||||
|
FROM oci.reg.darano.ir/mcr.microsoft.com/playwright:v1.62.0-noble AS worker
|
||||||
|
|
||||||
|
# Remove ALL third-party apt sources before running apt-get update.
|
||||||
|
# The Playwright image ships with NodeSource, GitHub CLI, etc. that
|
||||||
|
# all time out from our VPS location (Iran). Only keep Ubuntu repos.
|
||||||
|
RUN rm -rf /etc/apt/sources.list.d/*
|
||||||
|
|
||||||
|
# Install Python 3.12 + uv, then sync deps — all in one RUN.
|
||||||
|
# Use a mirror for apt since the VPS has network restrictions (Iran).
|
||||||
|
RUN printf 'Types: deb\nURIs: http://ubuntu.parsvds.com/ubuntu/\nSuites: noble noble-updates noble-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
|
&& . /usr/local/env 2>/dev/null || true \
|
||||||
|
|| pip install --break-system-packages \
|
||||||
|
--index-url https://pypi.reg.darano.ir/simple/ \
|
||||||
|
--trusted-host pypi.reg.darano.ir \
|
||||||
|
uv
|
||||||
|
|
||||||
|
ENV PATH="/usr/local/bin:$PATH" \
|
||||||
|
UV_DEFAULT_INDEX="https://pypi.reg.darano.ir" \
|
||||||
|
PIP_INDEX_URL="https://pypi.reg.darano.ir/simple/" \
|
||||||
|
# Chromium is pre-installed at /usr/bin/chromium
|
||||||
|
CHROME_BINARY=/usr/bin/chromium \
|
||||||
|
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
|
||||||
|
HEADLESS=true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy manifests first (stable cache layer)
|
||||||
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
|
||||||
|
# Install project deps into a venv targeting this image's Python 3.12
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
# Copy app code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# mkdir for volumes, user for security
|
||||||
|
RUN mkdir -p data logs recordings \
|
||||||
|
&& useradd -m -u 1001 seed 2>/dev/null || true \
|
||||||
|
&& chown -R seed:seed /app 2>/dev/null || true
|
||||||
|
|
||||||
|
USER seed
|
||||||
|
|
||||||
|
# CMD overridden in docker-compose.yml to use RQ worker
|
||||||
|
CMD ["rq", "worker", "--url", "redis://redis:6379/0", "batch"]
|
||||||
|
|||||||
@@ -85,10 +85,13 @@ batch: ## Run many parallel runners (WORKERS=3 RUNS=10 STAGGER=500)
|
|||||||
|
|
||||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
build: ## Build the Docker image
|
build: ## Build all Docker images (admin + worker)
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
up: ## Start admin panel in Docker (detached)
|
worker-build: ## Rebuild worker with Playwright base image
|
||||||
|
docker compose build worker
|
||||||
|
|
||||||
|
up: ## Start all services in Docker (detached)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@echo "$(GREEN)Admin panel:$(RESET) http://localhost:8000"
|
@echo "$(GREEN)Admin panel:$(RESET) http://localhost:8000"
|
||||||
|
|
||||||
@@ -101,16 +104,19 @@ logs: ## Follow container logs
|
|||||||
shell: ## Open a shell in the running admin container
|
shell: ## Open a shell in the running admin container
|
||||||
docker compose exec admin bash
|
docker compose exec admin bash
|
||||||
|
|
||||||
# Crawler one-shots inside Docker
|
# Worker one-off commands (override the default RQ worker command)
|
||||||
docker-otp: ## Run OTP scenario in Docker (PHONE=+98...)
|
docker-otp: ## Run OTP scenario in Docker (PHONE=+98...)
|
||||||
ifdef PHONE
|
ifdef PHONE
|
||||||
docker compose run --rm crawler python main.py otp --phone $(PHONE)
|
docker compose run --rm -e PHONE=$(PHONE) worker python main.py otp --phone $(PHONE)
|
||||||
else
|
else
|
||||||
docker compose run --rm crawler python main.py otp
|
docker compose run --rm worker python main.py otp
|
||||||
endif
|
endif
|
||||||
|
|
||||||
docker-fresh: ## Run fresh-session scenario in Docker
|
docker-fresh: ## Run fresh-session scenario in Docker
|
||||||
docker compose run --rm crawler python main.py fresh
|
docker compose run --rm worker python main.py fresh
|
||||||
|
|
||||||
|
docker-admin-up: ## Start only the admin panel in Docker (detached)
|
||||||
|
docker compose up -d admin
|
||||||
|
|
||||||
# ── Housekeeping ──────────────────────────────────────────────────────────────
|
# ── Housekeeping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ make down
|
|||||||
```
|
```
|
||||||
|
|
||||||
The SQLite database is stored in the `seed-data` Docker volume and persists across rebuilds.
|
The SQLite database is stored in the `seed-data` Docker volume and persists across rebuilds.
|
||||||
|
Worker browser sessions are recorded at 12 FPS into the shared `seed-recordings`
|
||||||
|
volume. The admin panel's **Recordings** tab lists and downloads the newest three
|
||||||
|
completed sessions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,6 +189,10 @@ Copy `.env.example` to `.env` and set values before running anything.
|
|||||||
| `DB_PATH` | `data/tracker.db` | Path to the SQLite file |
|
| `DB_PATH` | `data/tracker.db` | Path to the SQLite file |
|
||||||
| `HEADLESS` | `true` | Set to `false` to watch the browser (local only) |
|
| `HEADLESS` | `true` | Set to `false` to watch the browser (local only) |
|
||||||
| `CHROME_BINARY` | _(auto)_ | Explicit path to Chrome, e.g. `/usr/bin/google-chrome-stable` |
|
| `CHROME_BINARY` | _(auto)_ | Explicit path to Chrome, e.g. `/usr/bin/google-chrome-stable` |
|
||||||
|
| `RECORD_SESSIONS` | `false` | Record browser sessions; Docker workers override this to `true` |
|
||||||
|
| `RECORDING_FPS` | `12` | Recording frame rate |
|
||||||
|
| `RECORDING_MAX_FILES` | `3` | Number of completed recordings retained across all workers |
|
||||||
|
| `RECORDINGS_DIR` | `recordings` | Recording directory; Docker uses the shared `/app/recordings` volume |
|
||||||
| `PHONE_NUMBERS` | _(empty)_ | Comma-separated list of phone numbers for Scenario 1 |
|
| `PHONE_NUMBERS` | _(empty)_ | Comma-separated list of phone numbers for Scenario 1 |
|
||||||
| `FLOW_STEPS` | `step_home` | Comma-separated step names for the legacy step-registry runner |
|
| `FLOW_STEPS` | `step_home` | Comma-separated step names for the legacy step-registry runner |
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -30,8 +30,9 @@ def on_startup() -> None:
|
|||||||
if os.environ.get("SKIP_INTERNAL_WORKER", "").lower() in ("1", "true", "yes"):
|
if os.environ.get("SKIP_INTERNAL_WORKER", "").lower() in ("1", "true", "yes"):
|
||||||
return
|
return
|
||||||
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
rq_executable = str(Path(sys.executable).with_name("rq"))
|
||||||
_rq_worker = subprocess.Popen(
|
_rq_worker = subprocess.Popen(
|
||||||
[sys.executable, "-m", "rq", "worker", "--url", redis_url, "batch"],
|
[rq_executable, "worker", "--url", redis_url, "batch"],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
@@ -50,3 +51,8 @@ def on_shutdown() -> None:
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
def dashboard() -> FileResponse:
|
def dashboard() -> FileResponse:
|
||||||
return FileResponse(str(_STATIC / "dashboard.html"))
|
return FileResponse(str(_STATIC / "dashboard.html"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
def favicon() -> FileResponse:
|
||||||
|
return FileResponse(str(_STATIC / "favicon.svg"), media_type="image/svg+xml")
|
||||||
|
|||||||
+78
-1
@@ -3,12 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import speedtest # type: ignore[import-untyped]
|
import speedtest # type: ignore[import-untyped]
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from admin.models import (
|
from admin.models import (
|
||||||
delete_flow_config,
|
delete_flow_config,
|
||||||
@@ -88,6 +92,17 @@ class FlowConfigIn(BaseModel):
|
|||||||
scenario: str = "dynamic"
|
scenario: str = "dynamic"
|
||||||
stop_on_first_click: bool = False
|
stop_on_first_click: bool = False
|
||||||
|
|
||||||
|
@field_validator("target_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_url(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if value and "://" not in value:
|
||||||
|
value = f"https://{value}"
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError("Target URL must be a complete HTTP(S) URL")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _to_db_dict(body: FlowConfigIn) -> dict[str, Any]:
|
def _to_db_dict(body: FlowConfigIn) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -178,6 +193,11 @@ async def add_to_batch_queue(body: BatchTaskIn, _: Auth) -> dict[str, object]:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Flow config {body.config_id} not found"
|
status_code=404, detail=f"Flow config {body.config_id} not found"
|
||||||
)
|
)
|
||||||
|
if not str(cfg.get("target_url") or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="The selected flow config has no Target URL. Edit and save it first.",
|
||||||
|
)
|
||||||
|
|
||||||
result = enqueue(cfg, body.workers, body.total_runs, body.stagger_ms, body.headless)
|
result = enqueue(cfg, body.workers, body.total_runs, body.stagger_ms, body.headless)
|
||||||
log.info(
|
log.info(
|
||||||
@@ -232,6 +252,63 @@ async def list_batch_runs(_: Auth) -> list[dict[str, object]]:
|
|||||||
return await batch_runs_repo.list() # type: ignore[return-value]
|
return await batch_runs_repo.list() # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Browser-session recordings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _recording_path(filename: str) -> Path:
|
||||||
|
if filename != Path(filename).name or Path(filename).suffix.lower() != ".mp4":
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
|
directory = Path(config.recordings_dir).resolve()
|
||||||
|
path = directory / filename
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/recordings")
|
||||||
|
def list_recordings(_: Auth) -> list[dict[str, object]]:
|
||||||
|
directory = Path(config.recordings_dir)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for path in directory.glob("*.mp4"):
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stat_result = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": path.name,
|
||||||
|
"size_bytes": stat_result.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(
|
||||||
|
stat_result.st_mtime, tz=UTC
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/recordings/{filename}")
|
||||||
|
def download_recording(filename: str, _: Auth) -> FileResponse:
|
||||||
|
path = _recording_path(filename)
|
||||||
|
return FileResponse(path, media_type="video/mp4", filename=path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/recordings/{filename}", status_code=204)
|
||||||
|
def delete_recording(filename: str, _: Auth) -> None:
|
||||||
|
path = _recording_path(filename)
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found") from None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Log viewer
|
# Log viewer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Seed — Admin</title>
|
<title>Seed — Admin</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
|
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label>Target URL</label>
|
<label>Target URL</label>
|
||||||
<input id="cfg-target-url" type="url" placeholder="https://example.com" />
|
<input id="cfg-target-url" type="url" placeholder="https://example.com" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fg" style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
<div class="fg" style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
||||||
@@ -348,6 +349,7 @@
|
|||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="flows">Flow Configs</div>
|
<div class="tab" data-tab="flows">Flow Configs</div>
|
||||||
<div class="tab" data-tab="batch">Batch Run</div>
|
<div class="tab" data-tab="batch">Batch Run</div>
|
||||||
|
<div class="tab" data-tab="recordings">Recordings</div>
|
||||||
<div class="tab" data-tab="logs">Logs</div>
|
<div class="tab" data-tab="logs">Logs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,6 +591,29 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Recordings Tab ──────────────────────────────────────────── -->
|
||||||
|
<div class="tab-panel" id="tab-recordings">
|
||||||
|
<main>
|
||||||
|
<div class="panel" style="max-width:1100px">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" style="margin:0">Browser Session Recordings</div>
|
||||||
|
<div style="color:var(--muted);font-size:12px;margin-top:4px">The newest three completed worker sessions are retained at 12 FPS.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="loadRecordings()" style="margin-left:auto;padding:5px 14px;font-size:12px">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Recorded</th><th>Session</th><th>Size</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="recordings-tbody">
|
||||||
|
<tr><td colspan="4" class="empty">No recordings yet.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
|
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
|
||||||
<div class="tab-panel" id="tab-logs">
|
<div class="tab-panel" id="tab-logs">
|
||||||
<main>
|
<main>
|
||||||
@@ -712,6 +737,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|||||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||||
if (tab.dataset.tab === 'flows') loadConfigs();
|
if (tab.dataset.tab === 'flows') loadConfigs();
|
||||||
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
||||||
|
if (tab.dataset.tab === 'recordings') loadRecordings();
|
||||||
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
||||||
else _stopLogPoll();
|
else _stopLogPoll();
|
||||||
});
|
});
|
||||||
@@ -1164,6 +1190,7 @@ document.getElementById('cfg-digipay-match').addEventListener('change', e => {
|
|||||||
document.getElementById('form-save').addEventListener('click', async () => {
|
document.getElementById('form-save').addEventListener('click', async () => {
|
||||||
const body = collectForm();
|
const body = collectForm();
|
||||||
if (!body.name) { alert('Config name is required.'); return; }
|
if (!body.name) { alert('Config name is required.'); return; }
|
||||||
|
if (!body.target_url) { alert('Target URL is required.'); return; }
|
||||||
|
|
||||||
const cfgId = document.getElementById('cfg-id').value;
|
const cfgId = document.getElementById('cfg-id').value;
|
||||||
const isEdit = !!cfgId;
|
const isEdit = !!cfgId;
|
||||||
@@ -1383,6 +1410,11 @@ function startBatchPoll() {
|
|||||||
document.getElementById('batch-start-btn').addEventListener('click', async () => {
|
document.getElementById('batch-start-btn').addEventListener('click', async () => {
|
||||||
const configId = parseInt(document.getElementById('batch-config-select').value);
|
const configId = parseInt(document.getElementById('batch-config-select').value);
|
||||||
if (!configId) { alert('Please select a flow config.'); return; }
|
if (!configId) { alert('Please select a flow config.'); return; }
|
||||||
|
const selectedConfig = _configs.find(c => c.id === configId);
|
||||||
|
if (!selectedConfig?.target_url) {
|
||||||
|
alert('The selected flow config has no Target URL. Edit and save it first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const workers = parseInt(document.getElementById('batch-workers').value) || 3;
|
const workers = parseInt(document.getElementById('batch-workers').value) || 3;
|
||||||
const runs = parseInt(document.getElementById('batch-runs').value) || 10;
|
const runs = parseInt(document.getElementById('batch-runs').value) || 10;
|
||||||
const stagger = parseInt(document.getElementById('batch-stagger').value) || 0;
|
const stagger = parseInt(document.getElementById('batch-stagger').value) || 0;
|
||||||
@@ -1395,8 +1427,11 @@ document.getElementById('batch-start-btn').addEventListener('click', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
if (res.status === 404) { const d = await res.json(); alert(d.detail || 'Flow config not found.'); return; }
|
if (!res.ok) {
|
||||||
if (!res.ok) { alert('Failed to add task.'); return; }
|
const d = await res.json().catch(() => ({}));
|
||||||
|
alert(d.detail || 'Failed to add task.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
await pollBatchStatus();
|
await pollBatchStatus();
|
||||||
@@ -1421,6 +1456,56 @@ async function loadBatchHistory() {
|
|||||||
renderBatchHistory(await res.json());
|
renderBatchHistory(await res.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Recordings ─────────────────────── */
|
||||||
|
function fmtBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
const res = await apiFetch('/api/recordings');
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const rows = await res.json();
|
||||||
|
const tbody = document.getElementById('recordings-tbody');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No recordings yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(row => {
|
||||||
|
const encodedName = encodeURIComponent(row.name);
|
||||||
|
return `<tr>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(row.created_at)}</td>
|
||||||
|
<td style="font-family:monospace;font-size:12px">${esc(row.name)}</td>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtBytes(row.size_bytes)}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="btn" onclick="downloadRecording('${encodedName}')" style="padding:4px 10px;font-size:12px">↓ Download</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteRecording('${encodedName}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRecording(encodedName) {
|
||||||
|
const res = await apiFetch(`/api/recordings/${encodedName}`);
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const blobUrl = URL.createObjectURL(await res.blob());
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = decodeURIComponent(encodedName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecording(encodedName) {
|
||||||
|
if (!confirm(`Delete recording ${decodeURIComponent(encodedName)}?`)) return;
|
||||||
|
const res = await apiFetch(`/api/recordings/${encodedName}`, { method: 'DELETE' });
|
||||||
|
if (res && res.ok) await loadRecordings();
|
||||||
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── Logs ──────────────────────────── */
|
/* ─────────────────────────── Logs ──────────────────────────── */
|
||||||
let _logPollTimer = null;
|
let _logPollTimer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#6366f1"/>
|
||||||
|
<path d="M32 49V27" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
<path d="M31 30C19 30 12 23 13 13c11-1 19 5 18 17Z" fill="#a7f3d0"/>
|
||||||
|
<path d="M33 35c12 0 19-7 18-17-11-1-19 5-18 17Z" fill="#d1fae5"/>
|
||||||
|
<path d="M22 50h20" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -18,6 +18,10 @@ class Config:
|
|||||||
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
|
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
|
||||||
chrome_binary: str | None = os.getenv("CHROME_BINARY")
|
chrome_binary: str | None = os.getenv("CHROME_BINARY")
|
||||||
chromedriver_path: str = os.getenv("CHROMEDRIVER_PATH", "drivers/chromedriver")
|
chromedriver_path: str = os.getenv("CHROMEDRIVER_PATH", "drivers/chromedriver")
|
||||||
|
record_sessions: bool = os.getenv("RECORD_SESSIONS", "false").lower() == "true"
|
||||||
|
recording_fps: int = int(os.getenv("RECORDING_FPS", "12"))
|
||||||
|
recording_max_files: int = int(os.getenv("RECORDING_MAX_FILES", "3"))
|
||||||
|
recordings_dir: str = os.getenv("RECORDINGS_DIR", "recordings")
|
||||||
|
|
||||||
# Scenario 1 — SMS-OTP: list of phone numbers (one per line in env or comma-separated)
|
# Scenario 1 — SMS-OTP: list of phone numbers (one per line in env or comma-separated)
|
||||||
phone_numbers: list[str] = field(default_factory=list)
|
phone_numbers: list[str] = field(default_factory=list)
|
||||||
|
|||||||
+12
-3
@@ -27,10 +27,19 @@ def _single_run(
|
|||||||
|
|
||||||
identifier = f"batch-{batch_id[:8]}-{run_index:04d}"
|
identifier = f"batch-{batch_id[:8]}-{run_index:04d}"
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
log.info("[batch:%s] Run %d starting", batch_id[:8], run_index)
|
target_url = str(cfg.get("target_url") or "").strip()
|
||||||
|
log.info(
|
||||||
|
"[batch:%s] Run %d starting — target=%s",
|
||||||
|
batch_id[:8],
|
||||||
|
run_index,
|
||||||
|
target_url or "<missing>",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
target_url: str = cfg.get("target_url") or ""
|
with driver_session(
|
||||||
with driver_session(headless=headless) as driver:
|
headless=headless,
|
||||||
|
recording_name=identifier,
|
||||||
|
initial_url=target_url,
|
||||||
|
) as driver:
|
||||||
FreshSessionScenario(driver, target_url).run()
|
FreshSessionScenario(driver, target_url).run()
|
||||||
result = DynamicFlow(driver, cfg).run() if cfg else None
|
result = DynamicFlow(driver, cfg).run() if cfg else None
|
||||||
|
|
||||||
|
|||||||
+134
-11
@@ -4,19 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Any, Generator, cast
|
||||||
|
|
||||||
import undetected_chromedriver as uc
|
import undetected_chromedriver as uc
|
||||||
|
from selenium.common.exceptions import WebDriverException
|
||||||
from selenium.webdriver.chrome.options import Options
|
from selenium.webdriver.chrome.options import Options
|
||||||
from selenium_stealth import stealth
|
from selenium_stealth import stealth
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
|
from crawler.recording import SessionRecorder
|
||||||
from logger import get_logger
|
from logger import get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -56,7 +61,10 @@ def _detect_chrome() -> tuple[str, int]:
|
|||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
[resolved, "--version"], stderr=subprocess.DEVNULL, text=True, timeout=5
|
[resolved, "--version"], stderr=subprocess.DEVNULL, text=True, timeout=5
|
||||||
).strip()
|
).strip()
|
||||||
major = int(out.split()[-1].split(".")[0])
|
match = re.search(r"\b(\d+(?:\.\d+){3})\b", out)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
major = int(match.group(1).split(".")[0])
|
||||||
log.debug("Detected Chrome %d via '%s'", major, resolved)
|
log.debug("Detected Chrome %d via '%s'", major, resolved)
|
||||||
return resolved, major
|
return resolved, major
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -77,41 +85,102 @@ def _local_driver_path() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_options(user_agent: str, headless: bool, chrome_binary: str) -> Options:
|
def _build_options(
|
||||||
|
user_agent: str,
|
||||||
|
headless: bool,
|
||||||
|
chrome_binary: str,
|
||||||
|
profile_dir: str | None = None,
|
||||||
|
display: str | None = None,
|
||||||
|
) -> Options:
|
||||||
opts = Options()
|
opts = Options()
|
||||||
opts.binary_location = chrome_binary
|
opts.binary_location = chrome_binary
|
||||||
|
# Never let ChromeDriver session creation block on the initial New Tab
|
||||||
|
# renderer. Scenarios perform their own explicit URL/readiness waits.
|
||||||
|
opts.page_load_strategy = "none"
|
||||||
if headless:
|
if headless:
|
||||||
opts.add_argument("--headless=new")
|
opts.add_argument("--headless=new")
|
||||||
|
if profile_dir:
|
||||||
|
opts.add_argument(f"--user-data-dir={profile_dir}")
|
||||||
|
if display:
|
||||||
|
opts.add_argument(f"--display={display}")
|
||||||
|
opts.add_argument("--ozone-platform=x11")
|
||||||
opts.add_argument(f"--user-agent={user_agent}")
|
opts.add_argument(f"--user-agent={user_agent}")
|
||||||
opts.add_argument("--no-sandbox")
|
opts.add_argument("--no-sandbox")
|
||||||
opts.add_argument("--disable-dev-shm-usage")
|
|
||||||
opts.add_argument("--disable-gpu")
|
opts.add_argument("--disable-gpu")
|
||||||
|
opts.add_argument("--disable-logging")
|
||||||
|
opts.add_argument("--no-first-run")
|
||||||
|
opts.add_argument("--no-default-browser-check")
|
||||||
|
opts.add_argument("--disable-background-networking")
|
||||||
|
opts.add_argument("--disable-component-update")
|
||||||
|
opts.add_argument("--disable-default-apps")
|
||||||
|
opts.add_argument("--disable-sync")
|
||||||
|
opts.add_argument("--renderer-process-limit=2")
|
||||||
opts.add_argument("--disable-blink-features=AutomationControlled")
|
opts.add_argument("--disable-blink-features=AutomationControlled")
|
||||||
opts.add_argument("--disable-infobars")
|
opts.add_argument("--disable-infobars")
|
||||||
opts.add_argument("--window-size=1920,1080")
|
opts.add_argument("--window-size=1920,1080")
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
|
|
||||||
def make_driver(headless: bool | None = None) -> uc.Chrome:
|
def make_driver(
|
||||||
|
headless: bool | None = None,
|
||||||
|
profile_dir: str | None = None,
|
||||||
|
display: str | None = None,
|
||||||
|
initial_url: str | None = None,
|
||||||
|
) -> uc.Chrome:
|
||||||
"""Return a stealthed undetected Chrome instance."""
|
"""Return a stealthed undetected Chrome instance."""
|
||||||
use_headless = config.headless if headless is None else headless
|
use_headless = config.headless if headless is None else headless
|
||||||
chrome_binary, major = _detect_chrome()
|
chrome_binary, major = _detect_chrome()
|
||||||
ua = random.choice(_USER_AGENTS)
|
ua = random.choice(_USER_AGENTS)
|
||||||
opts = _build_options(ua, use_headless, chrome_binary)
|
opts = _build_options(
|
||||||
|
ua,
|
||||||
|
use_headless,
|
||||||
|
chrome_binary,
|
||||||
|
profile_dir,
|
||||||
|
display,
|
||||||
|
)
|
||||||
|
|
||||||
log.debug("Starting ChromeDriver (headless=%s, Chrome %d)", use_headless, major)
|
log.info(
|
||||||
|
"Starting ChromeDriver (headless=%s, Chrome %d, initial_url=%s)",
|
||||||
|
use_headless,
|
||||||
|
major,
|
||||||
|
initial_url or "<none>",
|
||||||
|
)
|
||||||
|
|
||||||
driver = uc.Chrome(
|
driver = uc.Chrome(
|
||||||
options=opts,
|
options=opts,
|
||||||
driver_executable_path=_local_driver_path(),
|
driver_executable_path=_local_driver_path(),
|
||||||
version_main=major,
|
version_main=major,
|
||||||
use_subprocess=True,
|
use_subprocess=True,
|
||||||
|
# undetected-chromedriver captures Chrome stdout/stderr in pipes but
|
||||||
|
# does not drain them. Fatal-only logging prevents a full pipe from
|
||||||
|
# freezing Chrome during renderer startup in Docker.
|
||||||
|
log_level=3,
|
||||||
)
|
)
|
||||||
|
log.info("ChromeDriver connected")
|
||||||
|
|
||||||
if not use_headless:
|
if initial_url and initial_url.startswith(("http://", "https://")):
|
||||||
|
navigation = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
driver.execute_cdp_cmd( # type: ignore[reportUnknownMemberType]
|
||||||
|
"Page.navigate", {"url": initial_url}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
error_text = navigation.get("errorText")
|
||||||
|
if error_text:
|
||||||
|
raise WebDriverException(
|
||||||
|
f"Chrome rejected initial navigation to {initial_url}: {error_text}"
|
||||||
|
)
|
||||||
|
log.info("Initial navigation dispatched to %s", initial_url)
|
||||||
|
|
||||||
|
log.info("Applying stealth settings")
|
||||||
|
|
||||||
|
if display:
|
||||||
|
driver.set_window_size(1920, 1080)
|
||||||
|
elif not use_headless:
|
||||||
driver.maximize_window()
|
driver.maximize_window()
|
||||||
else:
|
else:
|
||||||
driver.set_window_size(1920, 1080)
|
driver.set_window_size(1920, 1080)
|
||||||
|
driver.set_page_load_timeout(45)
|
||||||
|
|
||||||
stealth(
|
stealth(
|
||||||
driver,
|
driver,
|
||||||
@@ -135,13 +204,67 @@ def make_driver(headless: bool | None = None) -> uc.Chrome:
|
|||||||
return driver
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_processes(profile_dir: str) -> list[int]:
|
||||||
|
marker = profile_dir.encode()
|
||||||
|
pids: list[int] = []
|
||||||
|
for entry in Path("/proc").iterdir():
|
||||||
|
if not entry.name.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if marker in (entry / "cmdline").read_bytes():
|
||||||
|
pids.append(int(entry.name))
|
||||||
|
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||||
|
continue
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_profile_processes(profile_dir: str) -> None:
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||||
|
pids = _profile_processes(profile_dir)
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
os.kill(pid, sig)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
if sig == signal.SIGTERM and pids:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def driver_session(headless: bool | None = None) -> Generator[uc.Chrome, None, None]:
|
def driver_session(
|
||||||
driver = make_driver(headless)
|
headless: bool | None = None,
|
||||||
|
recording_name: str | None = None,
|
||||||
|
initial_url: str | None = None,
|
||||||
|
) -> Generator[uc.Chrome, None, None]:
|
||||||
|
profile_dir = tempfile.mkdtemp(prefix="seed-chrome-")
|
||||||
|
driver: uc.Chrome | None = None
|
||||||
|
recorder: SessionRecorder | None = None
|
||||||
|
display: str | None = None
|
||||||
|
effective_headless = config.headless if headless is None else headless
|
||||||
try:
|
try:
|
||||||
|
if config.record_sessions:
|
||||||
|
recorder = SessionRecorder(recording_name)
|
||||||
|
if recorder.start():
|
||||||
|
display = recorder.display
|
||||||
|
effective_headless = False
|
||||||
|
driver = make_driver(
|
||||||
|
effective_headless,
|
||||||
|
profile_dir=profile_dir,
|
||||||
|
display=display,
|
||||||
|
initial_url=initial_url,
|
||||||
|
)
|
||||||
yield driver
|
yield driver
|
||||||
finally:
|
finally:
|
||||||
driver.quit()
|
_terminate_profile_processes(profile_dir)
|
||||||
|
if driver is not None:
|
||||||
|
try:
|
||||||
|
driver.quit()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Driver shutdown error: %s", exc)
|
||||||
|
_terminate_profile_processes(profile_dir)
|
||||||
|
if recorder is not None:
|
||||||
|
recorder.stop()
|
||||||
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
log.debug("Driver session closed")
|
log.debug("Driver session closed")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Per-session Xvfb/FFmpeg recording with shared-volume retention."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_DISPLAY_LOCK = threading.Lock()
|
||||||
|
_SAFE_NAME = re.compile(r"[^A-Za-z0-9_.-]+")
|
||||||
|
_WIDTH = 1920
|
||||||
|
_HEIGHT = 1080
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_label(label: str | None) -> str:
|
||||||
|
cleaned = _SAFE_NAME.sub("-", label or "session").strip("-._")
|
||||||
|
return cleaned[:80] or "session"
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_process(process: subprocess.Popen[bytes] | None, *, interrupt: bool = False) -> None:
|
||||||
|
if process is None or process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
process.send_signal(signal.SIGINT if interrupt else signal.SIGTERM)
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_recordings(directory: Path) -> None:
|
||||||
|
keep = max(1, config.recording_max_files)
|
||||||
|
lock_path = directory / ".retention.lock"
|
||||||
|
with lock_path.open("a+b") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
recordings = sorted(
|
||||||
|
directory.glob("*.mp4"),
|
||||||
|
key=lambda path: path.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for stale in recordings[keep:]:
|
||||||
|
try:
|
||||||
|
stale.unlink()
|
||||||
|
log.info("Removed expired recording %s", stale.name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRecorder:
|
||||||
|
"""Own one virtual display and one FFmpeg recorder."""
|
||||||
|
|
||||||
|
def __init__(self, label: str | None = None) -> None:
|
||||||
|
self.label = _safe_label(label)
|
||||||
|
self.display: str | None = None
|
||||||
|
self._xvfb: subprocess.Popen[bytes] | None = None
|
||||||
|
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||||
|
self._temp_path: Path | None = None
|
||||||
|
self._final_path: Path | None = None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
directory = Path(config.recordings_dir)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
||||||
|
token = uuid.uuid4().hex[:8]
|
||||||
|
self._final_path = directory / f"{self.label}-{stamp}-{token}.mp4"
|
||||||
|
self._temp_path = directory / f".{self._final_path.name}.part.mp4"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _DISPLAY_LOCK:
|
||||||
|
self._start_xvfb()
|
||||||
|
self._start_ffmpeg()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Could not start session recording: %s", exc)
|
||||||
|
self.stop(publish=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Recording session to %s at %d FPS",
|
||||||
|
self._final_path.name,
|
||||||
|
config.recording_fps,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _start_xvfb(self) -> None:
|
||||||
|
for display_number in range(100, 1000):
|
||||||
|
socket_path = Path(f"/tmp/.X11-unix/X{display_number}")
|
||||||
|
if socket_path.exists():
|
||||||
|
continue
|
||||||
|
display = f":{display_number}"
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"Xvfb",
|
||||||
|
display,
|
||||||
|
"-screen",
|
||||||
|
"0",
|
||||||
|
f"{_WIDTH}x{_HEIGHT}x24",
|
||||||
|
"-nolisten",
|
||||||
|
"tcp",
|
||||||
|
"-ac",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
stderr = (process.stderr.read() if process.stderr else b"").decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"Xvfb exited early: {stderr.strip()}")
|
||||||
|
if socket_path.exists():
|
||||||
|
self.display = display
|
||||||
|
self._xvfb = process
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
_stop_process(process)
|
||||||
|
raise RuntimeError("no free X display was available")
|
||||||
|
|
||||||
|
def _start_ffmpeg(self) -> None:
|
||||||
|
if self.display is None or self._temp_path is None:
|
||||||
|
raise RuntimeError("virtual display is not ready")
|
||||||
|
self._ffmpeg = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"x11grab",
|
||||||
|
"-framerate",
|
||||||
|
str(config.recording_fps),
|
||||||
|
"-video_size",
|
||||||
|
f"{_WIDTH}x{_HEIGHT}",
|
||||||
|
"-i",
|
||||||
|
f"{self.display}.0",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-threads",
|
||||||
|
"1",
|
||||||
|
"-crf",
|
||||||
|
"28",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
str(self._temp_path),
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(0.2)
|
||||||
|
if self._ffmpeg.poll() is not None:
|
||||||
|
stderr = (self._ffmpeg.stderr.read() if self._ffmpeg.stderr else b"").decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"FFmpeg exited early: {stderr.strip()}")
|
||||||
|
|
||||||
|
def stop(self, *, publish: bool = True) -> Path | None:
|
||||||
|
_stop_process(self._ffmpeg, interrupt=True)
|
||||||
|
_stop_process(self._xvfb)
|
||||||
|
self._ffmpeg = None
|
||||||
|
self._xvfb = None
|
||||||
|
|
||||||
|
if self._temp_path is None or self._final_path is None:
|
||||||
|
return None
|
||||||
|
if not publish or not self._temp_path.exists() or self._temp_path.stat().st_size == 0:
|
||||||
|
self._temp_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
os.replace(self._temp_path, self._final_path)
|
||||||
|
_prune_recordings(self._final_path.parent)
|
||||||
|
log.info("Recording saved: %s", self._final_path.name)
|
||||||
|
return self._final_path
|
||||||
@@ -1,10 +1,37 @@
|
|||||||
"""Scenario 2 — fresh cookie jar per run so the site treats the visitor as a new user."""
|
"""Scenario 2 — fresh cookie jar per run so the site treats the visitor as a new user."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import undetected_chromedriver as uc
|
import undetected_chromedriver as uc
|
||||||
|
from selenium.common.exceptions import TimeoutException, WebDriverException
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
from crawler.driver import human_delay
|
from crawler.driver import human_delay
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
class FreshSessionError(RuntimeError):
|
||||||
|
"""The browser could not leave its initial blank page."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_url(url: str) -> str:
|
||||||
|
value = url.strip()
|
||||||
|
if value and "://" not in value:
|
||||||
|
value = f"https://{value}"
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise FreshSessionError(
|
||||||
|
"target_url must be a complete HTTP(S) URL, for example https://example.com"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_web_url(url: str) -> bool:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
class FreshSessionScenario:
|
class FreshSessionScenario:
|
||||||
@@ -12,39 +39,84 @@ class FreshSessionScenario:
|
|||||||
Opens the target URL in a brand-new browser profile (no stored cookies,
|
Opens the target URL in a brand-new browser profile (no stored cookies,
|
||||||
localStorage, or cache) so each run appears as a first-time visitor.
|
localStorage, or cache) so each run appears as a first-time visitor.
|
||||||
|
|
||||||
Because undetected-chromedriver already creates an isolated temp profile
|
Undetected-chromedriver creates an isolated temporary profile per instance,
|
||||||
per instance, simply spinning up a new driver is sufficient — but we also
|
so no cookie or storage clearing is needed before navigation.
|
||||||
explicitly delete all cookies after loading to be safe.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, driver: uc.Chrome, url: str, timeout: int = 30) -> None:
|
def __init__(self, driver: uc.Chrome, url: str, timeout: int = 30) -> None:
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
self.url = url
|
self.url = _normalise_url(url)
|
||||||
self.wait = WebDriverWait(driver, timeout)
|
self.wait = WebDriverWait(driver, timeout)
|
||||||
|
|
||||||
def run(self) -> dict[str, object]:
|
def run(self) -> dict[str, object]:
|
||||||
"""Navigate to the target, clear any residual state, return page info."""
|
"""Navigate to the target and return page information."""
|
||||||
self.driver.delete_all_cookies()
|
# Each driver already owns a brand-new temporary profile. Do not issue
|
||||||
|
# renderer commands on Chrome's initial blank tab: on small servers that
|
||||||
|
# renderer can stall before navigation is ever attempted.
|
||||||
|
log.info("Navigating browser to %s", self.url)
|
||||||
|
try:
|
||||||
|
navigation = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
self.driver.execute_cdp_cmd( # type: ignore[reportUnknownMemberType]
|
||||||
|
"Page.navigate", {"url": self.url}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
error_text = navigation.get("errorText")
|
||||||
|
if error_text:
|
||||||
|
raise FreshSessionError(f"Chrome rejected navigation: {error_text}")
|
||||||
|
except FreshSessionError:
|
||||||
|
raise
|
||||||
|
except WebDriverException:
|
||||||
|
# Older Chrome builds may not expose Page.navigate. Keep a normal
|
||||||
|
# WebDriver fallback, but only after navigation has been attempted.
|
||||||
|
try:
|
||||||
|
self.driver.get(self.url)
|
||||||
|
except TimeoutException:
|
||||||
|
log.warning("Page load timed out; stopping navigation")
|
||||||
|
self.driver.execute_cdp_cmd( # type: ignore[reportUnknownMemberType]
|
||||||
|
"Page.stopLoading", {}
|
||||||
|
)
|
||||||
|
|
||||||
# Clear localStorage / sessionStorage via JS
|
try:
|
||||||
self.driver.execute_script(
|
# Chrome's internal New Tab URLs (chrome://newtab and
|
||||||
"try { window.localStorage.clear(); window.sessionStorage.clear(); } catch(e) {}"
|
# chrome://new-tab-page) are not successful navigation. Wait until
|
||||||
)
|
# the address bar actually contains an HTTP(S) page or redirect.
|
||||||
|
self.wait.until(lambda d: _is_web_url(d.current_url))
|
||||||
|
except TimeoutException as exc:
|
||||||
|
try:
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
except WebDriverException:
|
||||||
|
current_url = "<unavailable>"
|
||||||
|
raise FreshSessionError(
|
||||||
|
f"navigation to {self.url} never reached a web page "
|
||||||
|
f"(Chrome remained at {current_url})"
|
||||||
|
) from exc
|
||||||
|
|
||||||
if not self.url:
|
human_delay(1.0, 2.0)
|
||||||
return {"title": "", "url": "", "cookie_count": 0, "cookies": []}
|
|
||||||
|
|
||||||
self.driver.get(self.url)
|
# Interactive is sufficient for the dynamic flow and avoids waiting on
|
||||||
human_delay(2.0, 4.0)
|
# analytics, ads, or other background resources indefinitely.
|
||||||
|
def document_is_ready(driver: uc.Chrome) -> bool:
|
||||||
|
state = cast(
|
||||||
|
str,
|
||||||
|
driver.execute_script( # type: ignore[reportUnknownMemberType]
|
||||||
|
"return document.readyState"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return state in ("interactive", "complete")
|
||||||
|
|
||||||
# Wait for the page to reach a ready state
|
try:
|
||||||
self.wait.until(
|
self.wait.until(document_is_ready)
|
||||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
except TimeoutException:
|
||||||
)
|
log.warning("Document did not report a ready state; continuing with the current DOM")
|
||||||
|
|
||||||
title = self.driver.title
|
title = self.driver.title
|
||||||
current_url = self.driver.current_url
|
current_url = self.driver.current_url
|
||||||
cookies = self.driver.get_cookies()
|
cookies = cast(
|
||||||
|
list[dict[str, Any]],
|
||||||
|
self.driver.get_cookies(), # type: ignore[reportUnknownMemberType]
|
||||||
|
)
|
||||||
|
log.info("Browser reached %s", current_url)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
+13
-4
@@ -304,12 +304,21 @@ def run_batch_job(batch_id: str, item: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
identifier = f"batch-{batch_id[:8]}-{slot:04d}"
|
identifier = f"batch-{batch_id[:8]}-{slot:04d}"
|
||||||
log.info("[batch:%s] slot %d starting", batch_id[:8], slot)
|
target_url = str(cfg.get("target_url") or "").strip()
|
||||||
|
scenario: str = cfg.get("scenario") or "dynamic"
|
||||||
|
log.info(
|
||||||
|
"[batch:%s] slot %d starting — target=%s",
|
||||||
|
batch_id[:8],
|
||||||
|
slot,
|
||||||
|
target_url or "<missing>",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_url: str = cfg.get("target_url") or ""
|
with driver_session(
|
||||||
scenario: str = cfg.get("scenario") or "dynamic"
|
headless=headless,
|
||||||
with driver_session(headless=headless) as driver:
|
recording_name=identifier,
|
||||||
|
initial_url=target_url,
|
||||||
|
) as driver:
|
||||||
FreshSessionScenario(driver, target_url).run()
|
FreshSessionScenario(driver, target_url).run()
|
||||||
if cfg:
|
if cfg:
|
||||||
if scenario == "digipay":
|
if scenario == "digipay":
|
||||||
|
|||||||
+35
-10
@@ -15,13 +15,17 @@ services:
|
|||||||
|
|
||||||
# ── Admin panel (FastAPI) ─────────────────────────────────────────────────────
|
# ── Admin panel (FastAPI) ─────────────────────────────────────────────────────
|
||||||
admin:
|
admin:
|
||||||
build: .
|
build:
|
||||||
image: seed:latest
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: admin
|
||||||
|
image: seed-admin:latest
|
||||||
|
hostname: crawler-admin
|
||||||
container_name: seed-admin
|
container_name: seed-admin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: python main.py admin
|
command: python main.py admin
|
||||||
ports:
|
ports:
|
||||||
- "${ADMIN_PORT:-8000}:8000"
|
- "${COMPOSE_ADMIN_PORT:-127.0.0.1:8800}:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -31,9 +35,11 @@ services:
|
|||||||
REDIS_URL: "redis://redis:6379/0"
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
SKIP_INTERNAL_WORKER: "true"
|
SKIP_INTERNAL_WORKER: "true"
|
||||||
HEADLESS: "true"
|
HEADLESS: "true"
|
||||||
|
RECORDINGS_DIR: /app/recordings
|
||||||
volumes:
|
volumes:
|
||||||
- seed-data:/app/data
|
- seed-data:/app/data
|
||||||
- seed-logs:/app/logs
|
- seed-logs:/app/logs
|
||||||
|
- seed-recordings:/app/recordings
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -43,14 +49,21 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- proxy-network
|
||||||
|
- default
|
||||||
|
|
||||||
# ── RQ worker (Chrome crawler, runs batch jobs) ───────────────────────────────
|
# ── RQ worker (Chrome crawler, runs batch jobs) ───────────────────────────────
|
||||||
worker:
|
worker:
|
||||||
build: .
|
build:
|
||||||
image: seed:latest
|
context: .
|
||||||
container_name: seed-worker
|
dockerfile: Dockerfile
|
||||||
|
target: worker
|
||||||
|
image: seed-worker:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: python -m rq worker --url redis://redis:6379/0 batch
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
command: ["rq", "worker", "--url", "redis://redis:6379/0", "batch"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -58,24 +71,36 @@ services:
|
|||||||
LOG_FILE: /app/logs/seed.log
|
LOG_FILE: /app/logs/seed.log
|
||||||
REDIS_URL: "redis://redis:6379/0"
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
HEADLESS: "true"
|
HEADLESS: "true"
|
||||||
CHROME_BINARY: /usr/bin/google-chrome-stable
|
RECORD_SESSIONS: "true"
|
||||||
|
RECORDING_FPS: "12"
|
||||||
|
RECORDING_MAX_FILES: "3"
|
||||||
|
RECORDINGS_DIR: /app/recordings
|
||||||
volumes:
|
volumes:
|
||||||
- seed-data:/app/data
|
- seed-data:/app/data
|
||||||
- seed-logs:/app/logs
|
- seed-logs:/app/logs
|
||||||
|
- seed-recordings:/app/recordings
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# Chrome needs extra shared memory to avoid OOM crashes
|
shm_size: "2gb"
|
||||||
shm_size: "256mb"
|
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_ADMIN
|
- SYS_ADMIN
|
||||||
security_opt:
|
security_opt:
|
||||||
- seccomp:unconfined
|
- seccomp:unconfined
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-network:
|
||||||
|
external: true
|
||||||
|
default:
|
||||||
|
name: crawle-snapp_default
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
seed-data:
|
seed-data:
|
||||||
driver: local
|
driver: local
|
||||||
seed-logs:
|
seed-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
seed-recordings:
|
||||||
|
driver: local
|
||||||
redis-data:
|
redis-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def run_otp(phone: str, cfg_id: int | None = None) -> None:
|
|||||||
target_url: str = flow_cfg.get("target_url") or "" if flow_cfg else ""
|
target_url: str = flow_cfg.get("target_url") or "" if flow_cfg else ""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
with driver_session() as driver:
|
with driver_session(initial_url=target_url) as driver:
|
||||||
OTPLoginScenario(driver, phone, otp_resolver, target_url).run()
|
OTPLoginScenario(driver, phone, otp_resolver, target_url).run()
|
||||||
result = DynamicFlow(driver, flow_cfg).run() if flow_cfg else None
|
result = DynamicFlow(driver, flow_cfg).run() if flow_cfg else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -91,7 +91,7 @@ def run_fresh(cfg_id: int | None = None) -> None:
|
|||||||
target_url: str = flow_cfg.get("target_url") or "" if flow_cfg else ""
|
target_url: str = flow_cfg.get("target_url") or "" if flow_cfg else ""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
with driver_session() as driver:
|
with driver_session(initial_url=target_url) as driver:
|
||||||
FreshSessionScenario(driver, target_url).run()
|
FreshSessionScenario(driver, target_url).run()
|
||||||
result = DynamicFlow(driver, flow_cfg).run() if flow_cfg else None
|
result = DynamicFlow(driver, flow_cfg).run() if flow_cfg else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ venv = ".venv"
|
|||||||
dev = [
|
dev = [
|
||||||
"pyright>=1.1.370",
|
"pyright>=1.1.370",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pypi"
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
default = true
|
||||||
|
|||||||
+46
-4
@@ -7,6 +7,7 @@ version matching the installed Chrome, then patches it with undetected_chromedri
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -22,6 +23,13 @@ from logger import get_logger
|
|||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_DOWNLOAD_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_chrome_full_version() -> tuple[int, str]:
|
def _get_chrome_full_version() -> tuple[int, str]:
|
||||||
candidates = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]
|
candidates = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]
|
||||||
@@ -35,7 +43,10 @@ def _get_chrome_full_version() -> tuple[int, str]:
|
|||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
[binary, "--version"], stderr=subprocess.DEVNULL, text=True, timeout=5
|
[binary, "--version"], stderr=subprocess.DEVNULL, text=True, timeout=5
|
||||||
).strip()
|
).strip()
|
||||||
full = out.split()[-1]
|
match = re.search(r"\b(\d+(?:\.\d+){3})\b", out)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
full = match.group(1)
|
||||||
major = int(full.split(".")[0])
|
major = int(full.split(".")[0])
|
||||||
log.info("Detected Chrome %s via '%s'", full, binary)
|
log.info("Detected Chrome %s via '%s'", full, binary)
|
||||||
return major, full
|
return major, full
|
||||||
@@ -49,7 +60,8 @@ def _get_chrome_full_version() -> tuple[int, str]:
|
|||||||
def _latest_chromedriver_version(major: int) -> str:
|
def _latest_chromedriver_version(major: int) -> str:
|
||||||
url = f"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{major}"
|
url = f"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{major}"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as resp:
|
request = urllib.request.Request(url, headers=_DOWNLOAD_HEADERS)
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as resp:
|
||||||
version = resp.read().decode().strip()
|
version = resp.read().decode().strip()
|
||||||
log.info("Latest ChromeDriver for Chrome %d: %s", major, version)
|
log.info("Latest ChromeDriver for Chrome %d: %s", major, version)
|
||||||
return version
|
return version
|
||||||
@@ -65,7 +77,8 @@ def _download_chromedriver(version: str, dest: Path) -> None:
|
|||||||
)
|
)
|
||||||
log.info("Downloading ChromeDriver %s …", version)
|
log.info("Downloading ChromeDriver %s …", version)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(zip_url, timeout=60) as resp:
|
request = urllib.request.Request(zip_url, headers=_DOWNLOAD_HEADERS)
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as resp:
|
||||||
data = resp.read()
|
data = resp.read()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.critical("Download failed: %s", exc)
|
log.critical("Download failed: %s", exc)
|
||||||
@@ -88,13 +101,42 @@ def _patch(dest: Path) -> None:
|
|||||||
log.info("Patch applied successfully")
|
log.info("Patch applied successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_existing_driver(dest: Path, chrome_major: int) -> bool:
|
||||||
|
if not dest.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[str(dest), "--version"], stderr=subprocess.DEVNULL, text=True, timeout=5
|
||||||
|
).strip()
|
||||||
|
driver_major = int(out.split()[1].split(".")[0])
|
||||||
|
except Exception as exc:
|
||||||
|
log.critical("Could not inspect ChromeDriver at %s: %s", dest, exc)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if driver_major != chrome_major:
|
||||||
|
log.critical(
|
||||||
|
"ChromeDriver major %d does not match Chrome major %d",
|
||||||
|
driver_major,
|
||||||
|
chrome_major,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info("Using packaged ChromeDriver %s", out.split()[1])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
dest = Path(config.chromedriver_path)
|
dest = Path(config.chromedriver_path)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
major, _ = _get_chrome_full_version()
|
major, _ = _get_chrome_full_version()
|
||||||
cd_version = _latest_chromedriver_version(major)
|
if _validate_existing_driver(dest, major):
|
||||||
|
_patch(dest)
|
||||||
|
log.info("ChromeDriver ready at %s", dest)
|
||||||
|
return
|
||||||
|
|
||||||
|
cd_version = _latest_chromedriver_version(major)
|
||||||
_download_chromedriver(cd_version, dest)
|
_download_chromedriver(cd_version, dest)
|
||||||
_patch(dest)
|
_patch(dest)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user