Record worker browser sessions

This commit is contained in:
2026-08-02 23:23:23 +03:30
parent 601b8ec69e
commit 66c8c19e0c
11 changed files with 384 additions and 14 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ def _single_run(
log.info("[batch:%s] Run %d starting", batch_id[:8], run_index)
try:
target_url: str = cfg.get("target_url") or ""
with driver_session(headless=headless) as driver:
with driver_session(headless=headless, recording_name=identifier) as driver:
FreshSessionScenario(driver, target_url).run()
result = DynamicFlow(driver, cfg).run() if cfg else None
+29 -4
View File
@@ -20,6 +20,7 @@ from selenium.webdriver.chrome.options import Options
from selenium_stealth import stealth
from config import config
from crawler.recording import SessionRecorder
from logger import get_logger
log = get_logger(__name__)
@@ -88,6 +89,7 @@ def _build_options(
headless: bool,
chrome_binary: str,
profile_dir: str | None = None,
display: str | None = None,
) -> Options:
opts = Options()
opts.binary_location = chrome_binary
@@ -96,6 +98,9 @@ def _build_options(
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("--no-sandbox")
opts.add_argument("--disable-gpu")
@@ -109,12 +114,13 @@ def _build_options(
def make_driver(
headless: bool | None = None,
profile_dir: str | None = None,
display: str | None = None,
) -> uc.Chrome:
"""Return a stealthed undetected Chrome instance."""
use_headless = config.headless if headless is None else headless
chrome_binary, major = _detect_chrome()
ua = random.choice(_USER_AGENTS)
opts = _build_options(ua, use_headless, chrome_binary, profile_dir)
opts = _build_options(ua, use_headless, chrome_binary, profile_dir, display)
log.debug("Starting ChromeDriver (headless=%s, Chrome %d)", use_headless, major)
@@ -125,7 +131,9 @@ def make_driver(
use_subprocess=True,
)
if not use_headless:
if display:
driver.set_window_size(1920, 1080)
elif not use_headless:
driver.maximize_window()
else:
driver.set_window_size(1920, 1080)
@@ -180,11 +188,26 @@ def _terminate_profile_processes(profile_dir: str) -> None:
@contextmanager
def driver_session(headless: bool | None = None) -> Generator[uc.Chrome, None, None]:
def driver_session(
headless: bool | None = None,
recording_name: 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:
driver = make_driver(headless, profile_dir=profile_dir)
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,
)
yield driver
finally:
_terminate_profile_processes(profile_dir)
@@ -194,6 +217,8 @@ def driver_session(headless: bool | None = None) -> Generator[uc.Chrome, None, N
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")
+186
View File
@@ -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 -1
View File
@@ -309,7 +309,7 @@ def run_batch_job(batch_id: str, item: dict[str, Any]) -> None:
try:
target_url: str = cfg.get("target_url") or ""
scenario: str = cfg.get("scenario") or "dynamic"
with driver_session(headless=headless) as driver:
with driver_session(headless=headless, recording_name=identifier) as driver:
FreshSessionScenario(driver, target_url).run()
if cfg:
if scenario == "digipay":