refactor: split browser session setup (#1156)

## ℹ️ Description

- Link to the related issue(s): N/A
- Split browser session setup into smaller, behavior-preserving helpers
so browser startup orchestration is easier to review and maintain.

## 📋 Changes Summary

- Extract remote debugging connection handling from browser session
creation.
- Extract launch argument, user-data-dir, Nodriver config, profile
preparation, and extension setup helpers.
- Add matching German translation entries for log messages moved into
helper functions.
- Leave lint thresholds unchanged because the diagnostics follow-up
remains separate.

### ⚙️ Type of Change

Select the type(s) of change(s) included in this pull request:
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

##  Checklist

Before requesting a review, confirm the following:
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Summary by CodeRabbit

* **Refactor**
* Improved browser session setup by separating remote-debugging
connections from fresh browser startup.
* Hardened launch argument construction and user data directory
resolution for more consistent configuration.
* Improved browser startup failure cleanup to ensure resources are
released reliably.
* Kept troubleshooting output behavior, including better guidance for
debugging scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-06-20 14:54:15 +02:00
committed by GitHub
parent c8fb72ae9e
commit 0b4b9eb397
2 changed files with 186 additions and 135 deletions

View File

@@ -717,33 +717,49 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
#################################################
create_browser_session:
"Creating Browser session...": "Erstelle Browser-Sitzung..."
"Using existing browser process at %s:%s": "Verwende existierenden Browser-Prozess unter %s:%s"
"New Browser session is %s": "Neue Browser-Sitzung ist %s"
" -> Browser binary location: %s": " -> Browser-Programmpfad: %s"
" -> Browser profile name: %s": " -> Browser-Profilname: %s"
" -> Browser user data dir: %s": " -> Browser-Benutzerdatenverzeichnis: %s"
" -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s"
"Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.": "Ignoriere leeres --user-data-dir= Argument; verwende konfiguriertes user_data_dir."
"Configured browser.user_data_dir (%s) does not match --user-data-dir argument (%s); using the argument value.": "Konfiguriertes browser.user_data_dir (%s) stimmt nicht mit --user-data-dir Argument (%s) überein; verwende Argument-Wert."
"Remote debugging detected, but browser configuration looks invalid: %s": "Remote-Debugging erkannt, aber Browser-Konfiguration scheint ungültig: %s"
" -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..."
" -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]"
"Failed to connect to browser. This error often occurs when:": "Fehler beim Verbinden mit dem Browser. Dieser Fehler tritt häufig auf, wenn:"
"Failed to start browser. This error often occurs when:": "Fehler beim Starten des Browsers. Dieser Fehler tritt häufig auf, wenn:"
"1. Running as root user (try running as regular user)": "1. Als Root-Benutzer ausgeführt wird (versuchen Sie es als normaler Benutzer)"
"2. Browser profile is locked or in use by another process": "2. Das Browser-Profil gesperrt oder von einem anderen Prozess verwendet wird"
"3. Insufficient permissions to access the browser profile": "3. Unzureichende Berechtigungen für den Zugriff auf das Browser-Profil"
"4. Browser is not properly started with remote debugging enabled": "4. Der Browser nicht ordnungsgemäß mit aktiviertem Remote-Debugging gestartet wurde"
"4. Browser binary is not executable or missing": "4. Die Browser-Binärdatei nicht ausführbar oder fehlend ist"
"5. Check if any antivirus or security software is blocking the browser": "5. Überprüfen Sie, ob Antiviren- oder Sicherheitssoftware den Browser blockiert"
"Troubleshooting steps:": "Schritte zur Fehlerbehebung:"
"1. Close all browser instances and try again": "1. Schließen Sie alle Browser-Instanzen und versuchen Sie es erneut"
"2. Remove the user_data_dir configuration temporarily": "2. Entfernen Sie die user_data_dir-Konfiguration vorübergehend"
"3. Start browser manually with: %s --remote-debugging-port=%d": "3. Starten Sie den Browser manuell mit: %s --remote-debugging-port=%d"
"3. Try running without profile configuration": "3. Versuchen Sie es ohne Profil-Konfiguration"
"4. Check browser binary permissions: %s": "4. Überprüfen Sie die Browser-Binärdatei-Berechtigungen: %s"
_connect_to_remote_browser:
"Using existing browser process at %s:%s": "Verwende existierenden Browser-Prozess unter %s:%s"
"Failed to connect to browser. This error often occurs when:": "Fehler beim Verbinden mit dem Browser. Dieser Fehler tritt häufig auf, wenn:"
"1. Running as root user (try running as regular user)": "1. Als Root-Benutzer ausgeführt wird (versuchen Sie es als normaler Benutzer)"
"2. Browser profile is locked or in use by another process": "2. Das Browser-Profil gesperrt oder von einem anderen Prozess verwendet wird"
"3. Insufficient permissions to access the browser profile": "3. Unzureichende Berechtigungen für den Zugriff auf das Browser-Profil"
"4. Browser is not properly started with remote debugging enabled": "4. Der Browser nicht ordnungsgemäß mit aktiviertem Remote-Debugging gestartet wurde"
"Troubleshooting steps:": "Schritte zur Fehlerbehebung:"
"1. Close all browser instances and try again": "1. Schließen Sie alle Browser-Instanzen und versuchen Sie es erneut"
"2. Remove the user_data_dir configuration temporarily": "2. Entfernen Sie die user_data_dir-Konfiguration vorübergehend"
"3. Start browser manually with: %s --remote-debugging-port=%d": "3. Starten Sie den Browser manuell mit: %s --remote-debugging-port=%d"
"4. Check if any antivirus or security software is blocking the connection": "4. Überprüfen Sie, ob Antiviren- oder Sicherheitssoftware die Verbindung blockiert"
_build_new_browser_launch_args:
" -> Browser profile name: %s": " -> Browser-Profilname: %s"
" -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s"
"Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.": "Ignoriere leeres --user-data-dir= Argument; verwende konfiguriertes user_data_dir."
_resolve_effective_user_data_dir:
"Configured browser.user_data_dir (%s) does not match --user-data-dir argument (%s); using the argument value.": "Konfiguriertes browser.user_data_dir (%s) stimmt nicht mit --user-data-dir Argument (%s) überein; verwende Argument-Wert."
_prepare_browser_profile:
" -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..."
_add_browser_extensions:
" -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]"
web_check:
"Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s"

View File

@@ -435,151 +435,35 @@ class WebScrapingMixin: # noqa: PLR0904
########################################################
# check if an existing browser instance shall be used...
########################################################
remote_host = "127.0.0.1"
remote_port = 0
for arg in self.browser_config.arguments:
if arg.startswith("--remote-debugging-host="):
remote_host = arg.split("=", maxsplit = 1)[1]
if arg.startswith("--remote-debugging-port="):
remote_port = int(arg.split("=", maxsplit = 1)[1])
remote_host, remote_port = self._parse_remote_debugging_args(self.browser_config.arguments)
if remote_port > 0:
LOG.info("Using existing browser process at %s:%s", remote_host, remote_port)
# Enhanced port checking with retry logic
port_available = await self._check_port_with_retry(remote_host, remote_port)
ensure(
port_available,
f"Browser process not reachable at {remote_host}:{remote_port}. "
f"Start the browser with --remote-debugging-port={remote_port} or remove this port from your config.yaml. "
f"Make sure the browser is running and the port is not blocked by firewall.",
)
try:
cfg = NodriverConfig(
browser_executable_path = self.browser_config.binary_location # actually not necessary but nodriver fails without
)
cfg.host = remote_host
cfg.port = remote_port
self.browser = await nodriver.start(cfg) # type: ignore[attr-defined]
LOG.info("New Browser session is %s", self.browser.websocket_url)
return
except Exception as e:
error_msg = str(e)
if "root" in error_msg.lower():
LOG.error("Failed to connect to browser. This error often occurs when:")
LOG.error("1. Running as root user (try running as regular user)")
LOG.error("2. Browser profile is locked or in use by another process")
LOG.error("3. Insufficient permissions to access the browser profile")
LOG.error("4. Browser is not properly started with remote debugging enabled")
LOG.error("")
LOG.error("Troubleshooting steps:")
LOG.error("1. Close all browser instances and try again")
LOG.error("2. Remove the user_data_dir configuration temporarily")
LOG.error("3. Start browser manually with: %s --remote-debugging-port=%d", self.browser_config.binary_location, remote_port)
LOG.error("4. Check if any antivirus or security software is blocking the connection")
raise
self.browser = await self._connect_to_remote_browser(remote_host, remote_port)
LOG.info("New Browser session is %s", self.browser.websocket_url)
return
########################################################
# configure and initialize new browser instance...
########################################################
# default_browser_args: @ https://github.com/Second-Hand-Friends/nodriver/blob/b0d1f0a59cd16e0d9001f32f35a663129e403efd/nodriver/core/config.py
# https://peter.sh/experiments/chromium-command-line-switches/
# https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
browser_args = [
# "--disable-dev-shm-usage", # https://stackoverflow.com/a/50725918/5116073
"--disable-crash-reporter",
"--disable-domain-reliability",
"--disable-sync",
"--no-experiments",
"--disable-search-engine-choice-screen",
"--disable-features=MediaRouter",
"--use-mock-keychain",
"--test-type", # https://stackoverflow.com/a/36746675/5116073
# https://chromium.googlesource.com/chromium/src/+/master/net/dns/README.md#request-remapping
'--host-resolver-rules="MAP connect.facebook.net 127.0.0.1, MAP securepubads.g.doubleclick.net 127.0.0.1, MAP www.googletagmanager.com 127.0.0.1"',
]
browser_args, user_data_dir_from_args = self._build_new_browser_launch_args()
effective_user_data_dir = await self._resolve_effective_user_data_dir(user_data_dir_from_args)
is_edge = "edge" in self.browser_config.binary_location.lower()
if is_edge:
os.environ["MSEDGEDRIVER_TELEMETRY_OPTOUT"] = "1" # https://docs.microsoft.com/en-us/microsoft-edge/privacy-whitepaper/#microsoft-edge-driver
if self.browser_config.use_private_window:
browser_args.append("-inprivate" if is_edge else "--incognito")
if self.browser_config.profile_name:
LOG.info(" -> Browser profile name: %s", self.browser_config.profile_name)
browser_args.append(f"--profile-directory={self.browser_config.profile_name}")
user_data_dir_from_args:str | None = None
for browser_arg in self.browser_config.arguments:
LOG.info(" -> Custom Browser argument: %s", browser_arg)
if browser_arg.startswith("--user-data-dir="):
raw = browser_arg.split("=", maxsplit = 1)[1].strip().strip('"').strip("'")
if not raw:
LOG.warning("Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.")
continue
user_data_dir_from_args = raw
continue
browser_args.append(browser_arg)
effective_user_data_dir = user_data_dir_from_args or self.browser_config.user_data_dir
if user_data_dir_from_args and self.browser_config.user_data_dir:
arg_path, cfg_path = await asyncio.get_running_loop().run_in_executor(
None,
_resolve_user_data_dir_paths,
user_data_dir_from_args,
self.browser_config.user_data_dir,
)
if arg_path is None or cfg_path is None or arg_path != cfg_path:
LOG.warning(
"Configured browser.user_data_dir (%s) does not match --user-data-dir argument (%s); using the argument value.",
self.browser_config.user_data_dir,
user_data_dir_from_args,
)
if not effective_user_data_dir and not is_test_environment:
LOG.debug("No effective browser user_data_dir found. Browser will use its default profile location.")
self.browser_config.user_data_dir = effective_user_data_dir
if not loggers.is_debug(LOG):
browser_args.append("--log-level=3") # INFO: 0, WARNING: 1, ERROR: 2, FATAL: 3
if self.browser_config.user_data_dir:
LOG.info(" -> Browser user data dir: %s", self.browser_config.user_data_dir)
cfg = NodriverConfig(
headless = False,
browser_executable_path = self.browser_config.binary_location,
browser_args = browser_args,
user_data_dir = self.browser_config.user_data_dir,
)
# When --no-sandbox is in browser_args, nodriver's Config.sandbox must also be set to False.
# Otherwise nodriver re-adds --no-sandbox itself but still runs internal sandbox-related logic
# that can cause startup failures in containerized environments (Docker, LXC, etc.).
if any(arg == "--no-sandbox" for arg in browser_args):
cfg.sandbox = False
# already logged by nodriver:
# LOG.debug("-> Effective browser arguments: \n\t\t%s", "\n\t\t".join(cfg.browser_args))
cfg = self._build_nodriver_config(self.browser_config.binary_location, browser_args, effective_user_data_dir)
# Enhanced profile directory handling
if cfg.user_data_dir:
xdg_paths.ensure_directory(Path(cfg.user_data_dir), "browser profile directory")
profile_dir = os.path.join(cfg.user_data_dir, self.browser_config.profile_name or "Default")
os.makedirs(profile_dir, exist_ok = True)
prefs_file = os.path.join(profile_dir, "Preferences")
if not await files.exists(prefs_file):
LOG.info(" -> Setting chrome prefs [%s]...", prefs_file)
await asyncio.get_running_loop().run_in_executor(None, _write_initial_prefs, prefs_file)
await self._prepare_browser_profile(cfg.user_data_dir, self.browser_config.profile_name)
# load extensions
for crx_extension in self.browser_config.extensions:
LOG.info(" -> Adding Browser extension: [%s]", crx_extension)
ensure(await files.exists(crx_extension), f"Configured extension-file [{crx_extension}] does not exist.")
cfg.add_extension(crx_extension)
await self._add_browser_extensions(cfg)
try:
self.browser = await nodriver.start(cfg) # type: ignore[attr-defined]
@@ -604,6 +488,157 @@ class WebScrapingMixin: # noqa: PLR0904
LOG.error("5. Check if any antivirus or security software is blocking the browser")
raise
@staticmethod
def _parse_remote_debugging_args(arguments:Iterable[str]) -> tuple[str, int]:
"""Parse remote debugging host and port from browser arguments."""
remote_host = "127.0.0.1"
remote_port = 0
for arg in arguments:
if arg.startswith("--remote-debugging-host="):
remote_host = arg.split("=", maxsplit = 1)[1]
if arg.startswith("--remote-debugging-port="):
remote_port = int(arg.split("=", maxsplit = 1)[1])
return remote_host, remote_port
async def _connect_to_remote_browser(self, host:str, port:int) -> Browser:
"""Connect to an existing browser process via remote debugging."""
LOG.info("Using existing browser process at %s:%s", host, port)
port_available = await self._check_port_with_retry(host, port)
ensure(
port_available,
f"Browser process not reachable at {host}:{port}. "
f"Start the browser with --remote-debugging-port={port} or remove this port from your config.yaml. "
f"Make sure the browser is running and the port is not blocked by firewall.",
)
try:
cfg = NodriverConfig(
browser_executable_path = self.browser_config.binary_location # actually not necessary but nodriver fails without
)
cfg.host = host
cfg.port = port
return await nodriver.start(cfg) # type: ignore[attr-defined]
except Exception as e:
error_msg = str(e)
if "root" in error_msg.lower():
LOG.error("Failed to connect to browser. This error often occurs when:")
LOG.error("1. Running as root user (try running as regular user)")
LOG.error("2. Browser profile is locked or in use by another process")
LOG.error("3. Insufficient permissions to access the browser profile")
LOG.error("4. Browser is not properly started with remote debugging enabled")
LOG.error("")
LOG.error("Troubleshooting steps:")
LOG.error("1. Close all browser instances and try again")
LOG.error("2. Remove the user_data_dir configuration temporarily")
LOG.error("3. Start browser manually with: %s --remote-debugging-port=%d", self.browser_config.binary_location, port)
LOG.error("4. Check if any antivirus or security software is blocking the connection")
raise
def _build_new_browser_launch_args(self) -> tuple[list[str], str | None]:
"""Build browser launch arguments and extract user_data_dir from custom args.
Returns (browser_args, user_data_dir_from_args).
"""
# default_browser_args: @ https://github.com/Second-Hand-Friends/nodriver/blob/b0d1f0a59cd16e0d9001f32f35a663129e403efd/nodriver/core/config.py
# https://peter.sh/experiments/chromium-command-line-switches/
# https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
browser_args:list[str] = [
# "--disable-dev-shm-usage", # https://stackoverflow.com/a/50725918/5116073
"--disable-crash-reporter",
"--disable-domain-reliability",
"--disable-sync",
"--no-experiments",
"--disable-search-engine-choice-screen",
"--disable-features=MediaRouter",
"--use-mock-keychain",
"--test-type", # https://stackoverflow.com/a/36746675/5116073
# https://chromium.googlesource.com/chromium/src/+/master/net/dns/README.md#request-remapping
'--host-resolver-rules="MAP connect.facebook.net 127.0.0.1, MAP securepubads.g.doubleclick.net 127.0.0.1, MAP www.googletagmanager.com 127.0.0.1"',
]
is_edge = "edge" in (self.browser_config.binary_location or "").lower()
if is_edge:
os.environ["MSEDGEDRIVER_TELEMETRY_OPTOUT"] = "1" # https://docs.microsoft.com/en-us/microsoft-edge/privacy-whitepaper/#microsoft-edge-driver
if self.browser_config.use_private_window:
browser_args.append("-inprivate" if is_edge else "--incognito")
if self.browser_config.profile_name:
LOG.info(" -> Browser profile name: %s", self.browser_config.profile_name)
browser_args.append(f"--profile-directory={self.browser_config.profile_name}")
user_data_dir_from_args:str | None = None
for browser_arg in self.browser_config.arguments:
LOG.info(" -> Custom Browser argument: %s", browser_arg)
if browser_arg.startswith("--user-data-dir="):
raw = browser_arg.split("=", maxsplit = 1)[1].strip().strip('"').strip("'")
if not raw:
LOG.warning("Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.")
continue
user_data_dir_from_args = raw
continue
browser_args.append(browser_arg)
if not loggers.is_debug(LOG):
browser_args.append("--log-level=3") # INFO: 0, WARNING: 1, ERROR: 2, FATAL: 3
return browser_args, user_data_dir_from_args
async def _resolve_effective_user_data_dir(self, user_data_dir_from_args:str | None) -> str | None:
"""Resolve the effective user data directory, handling args vs config conflicts."""
effective = user_data_dir_from_args or self.browser_config.user_data_dir
if user_data_dir_from_args and self.browser_config.user_data_dir:
arg_path, cfg_path = await asyncio.get_running_loop().run_in_executor(
None,
_resolve_user_data_dir_paths,
user_data_dir_from_args,
self.browser_config.user_data_dir,
)
if arg_path is None or cfg_path is None or arg_path != cfg_path:
LOG.warning(
"Configured browser.user_data_dir (%s) does not match --user-data-dir argument (%s); using the argument value.",
self.browser_config.user_data_dir,
user_data_dir_from_args,
)
return effective
@staticmethod
def _build_nodriver_config(browser_path:str, browser_args:list[str], user_data_dir:str | None) -> NodriverConfig:
"""Build NodriverConfig and apply sandbox setting.
When --no-sandbox is in browser_args, nodriver's Config.sandbox must also be set to False.
Otherwise nodriver re-adds --no-sandbox itself but still runs internal sandbox-related logic
that can cause startup failures in containerized environments (Docker, LXC, etc.).
"""
cfg = NodriverConfig(
headless = False,
browser_executable_path = browser_path,
browser_args = browser_args,
user_data_dir = user_data_dir,
)
if any(arg == "--no-sandbox" for arg in browser_args):
cfg.sandbox = False
return cfg
async def _prepare_browser_profile(self, user_data_dir:str, profile_name:str | None) -> None:
"""Ensure profile directory exists and write initial browser preferences."""
xdg_paths.ensure_directory(Path(user_data_dir), "browser profile directory")
profile_dir = Path(user_data_dir) / (profile_name or "Default")
profile_dir.mkdir(parents = True, exist_ok = True)
prefs_file = profile_dir / "Preferences"
if not await files.exists(prefs_file):
LOG.info(" -> Setting chrome prefs [%s]...", prefs_file)
await asyncio.get_running_loop().run_in_executor(None, _write_initial_prefs, str(prefs_file))
async def _add_browser_extensions(self, cfg:NodriverConfig) -> None:
"""Add configured browser extensions to the nodriver config."""
for crx_extension in self.browser_config.extensions:
LOG.info(" -> Adding Browser extension: [%s]", crx_extension)
ensure(await files.exists(crx_extension), f"Configured extension-file [{crx_extension}] does not exist.")
cfg.add_extension(crx_extension)
async def _check_port_with_retry(self, host:str, port:int, max_retries:int = 3, retry_delay:float = 1.0) -> bool:
"""
Check if a port is open with retry logic.