From fa9df6fca46aa57a3742b377c4f4eb81e3f2401c Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:01:05 +0100 Subject: [PATCH] feat: keep login selector fallbacks close to auth flow (#855) --- src/kleinanzeigen_bot/__init__.py | 38 ++++++++----- .../resources/translations.de.yaml | 5 +- tests/unit/test_init.py | 57 +++++++++++++++++++ 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 91419ae..d5a4ee3 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -7,7 +7,7 @@ import urllib.parse as urllib_parse from datetime import datetime from gettext import gettext as _ from pathlib import Path -from typing import Any, Final, cast +from typing import Any, Final, Sequence, cast import certifi, colorama, nodriver # isort: skip from nodriver.core.connection import ProtocolException @@ -34,10 +34,19 @@ LOG.setLevel(loggers.INFO) PUBLISH_MAX_RETRIES:Final[int] = 3 _NUMERIC_IDS_RE:Final[re.Pattern[str]] = re.compile(r"^\d+(,\d+)*$") +_LOGIN_DETECTION_SELECTORS:Final[list[tuple["By", str]]] = [ + (By.CLASS_NAME, "mr-medium"), + (By.ID, "user-email"), +] +_LOGIN_DETECTION_SELECTOR_LABELS:Final[tuple[str, ...]] = ("user_info_primary", "user_info_secondary") colorama.just_fix_windows_console() +def _format_login_detection_selectors(selectors:Sequence[tuple["By", str]]) -> str: + return ", ".join(f"{selector_type.name}={selector_value}" for selector_type, selector_value in selectors) + + class AdUpdateStrategy(enum.Enum): REPLACE = enum.auto() MODIFY = enum.auto() @@ -1245,38 +1254,39 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 login_check_timeout, effective_timeout, ) - - login_selectors = [ - (By.CLASS_NAME, "mr-medium"), - (By.ID, "user-email"), - ] - primary_selector_index = 0 + tried_login_selectors = _format_login_detection_selectors(_LOGIN_DETECTION_SELECTORS) try: user_info, matched_selector = await self.web_text_first_available( - login_selectors, + _LOGIN_DETECTION_SELECTORS, timeout = login_check_timeout, key = "login_detection", description = "login_detection(selector_group)", ) if username in user_info.lower(): - if matched_selector == primary_selector_index: - LOG.debug("Login detected via .mr-medium element") - else: - LOG.debug("Login detected via #user-email element") + matched_selector_label = ( + _LOGIN_DETECTION_SELECTOR_LABELS[matched_selector] + if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTOR_LABELS) + else f"selector_index_{matched_selector}" + ) + LOG.debug("Login detected via login detection selector '%s'", matched_selector_label) return True except TimeoutError: LOG.debug("Timeout waiting for login detection selector group after %.1fs", effective_timeout) if not include_probe: - LOG.debug("No login detected - neither .mr-medium nor #user-email found with username") + LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors) return False state = await self._auth_probe_login_state() if state == LoginState.LOGGED_IN: return True - LOG.debug("No login detected - DOM elements not found and server probe returned %s", state.name) + LOG.debug( + "No login detected - DOM login detection selectors (%s) did not confirm login and server probe returned %s", + tried_login_selectors, + state.name, + ) return False async def _fetch_published_ads(self) -> list[dict[str, Any]]: diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 90d4045..8d99c07 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -97,11 +97,8 @@ kleinanzeigen_bot/__init__.py: is_logged_in: "Starting login detection (timeout: %.1fs base, %.1fs effective with multiplier/backoff)": "Starte Login-Erkennung (Timeout: %.1fs Basis, %.1fs effektiv mit Multiplikator/Backoff)" - "Login detected via .mr-medium element": "Login erkannt über .mr-medium Element" - "Login detected via #user-email element": "Login erkannt über #user-email Element" + "Login detected via login detection selector '%s'": "Login erkannt über Login-Erkennungs-Selektor '%s'" "Timeout waiting for login detection selector group after %.1fs": "Timeout beim Warten auf die Login-Erkennungs-Selektorgruppe nach %.1fs" - "No login detected - neither .mr-medium nor #user-email found with username": "Kein Login erkannt - weder .mr-medium noch #user-email mit Benutzername gefunden" - "No login detected - DOM elements not found and server probe returned %s": "Kein Login erkannt - DOM-Elemente nicht gefunden und Server-Probe ergab %s" handle_after_login_logic: "# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen." diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 4367b05..6f5da65 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -483,6 +483,63 @@ class TestKleinanzeigenBotAuthentication: assert call_args.kwargs["key"] == "login_detection" assert call_args.kwargs["timeout"] == test_bot._timeout("login_detection") + @pytest.mark.asyncio + async def test_is_logged_in_logs_selector_label_without_raw_selector_literals( + self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture + ) -> None: + """Login detection logs should reference stable labels, not raw selector values.""" + caplog.set_level("DEBUG") + + with ( + caplog.at_level("DEBUG"), + patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("angemeldet als: dummy_user", 1)), + ): + assert await test_bot.is_logged_in(include_probe = False) is True + + assert "Login detected via login detection selector 'user_info_secondary'" in caplog.text + for forbidden in (".mr-medium", "#user-email", "mr-medium", "user-email"): + assert forbidden not in caplog.text + + @pytest.mark.asyncio + async def test_is_logged_in_logs_generic_message_when_selector_group_does_not_match( + self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture + ) -> None: + """Missing selector-group match should log the tried selectors when probe is disabled.""" + caplog.set_level("DEBUG") + + with ( + caplog.at_level("DEBUG"), + patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError), + ): + assert await test_bot.is_logged_in(include_probe = False) is False + + assert any( + record.message == "No login detected via configured login detection selectors (CLASS_NAME=mr-medium, ID=user-email)" + for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_is_logged_in_logs_raw_selectors_when_probe_reports_logged_out( + self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture + ) -> None: + """Probe-based final failure should include the tried raw selectors for debugging.""" + caplog.set_level("DEBUG") + + with ( + caplog.at_level("DEBUG"), + patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError), + patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT), + ): + assert await test_bot.is_logged_in() is False + + assert any( + record.message == ( + "No login detected - DOM login detection selectors (CLASS_NAME=mr-medium, ID=user-email) " + "did not confirm login and server probe returned LOGGED_OUT" + ) + for record in caplog.records + ) + @pytest.mark.asyncio async def test_get_login_state_prefers_dom_over_auth_probe(self, test_bot:KleinanzeigenBot) -> None: with (