Record worker browser sessions
This commit is contained in:
+1
-1
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user