mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-16 04:11:50 +01:00
Compare commits
7 Commits
dependenci
...
fix/877-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47c6311eb | ||
|
|
1abe233de5 | ||
|
|
6e562164b8 | ||
|
|
62fd5f6003 | ||
|
|
868f81239a | ||
|
|
67a4db0db6 | ||
|
|
03dbd54e85 |
44
pdm.lock
generated
44
pdm.lock
generated
@@ -606,13 +606,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.25.1"
|
||||
version = "3.25.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A platform independent file lock."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf"},
|
||||
{file = "filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9"},
|
||||
{file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"},
|
||||
{file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1745,29 +1745,29 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.5"
|
||||
version = "0.15.6"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c"},
|
||||
{file = "ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080"},
|
||||
{file = "ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a"},
|
||||
{file = "ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca"},
|
||||
{file = "ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd"},
|
||||
{file = "ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d"},
|
||||
{file = "ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752"},
|
||||
{file = "ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2"},
|
||||
{file = "ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74"},
|
||||
{file = "ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe"},
|
||||
{file = "ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b"},
|
||||
{file = "ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2"},
|
||||
{file = "ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff"},
|
||||
{file = "ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3"},
|
||||
{file = "ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0"},
|
||||
{file = "ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c"},
|
||||
{file = "ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406"},
|
||||
{file = "ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837"},
|
||||
{file = "ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -20,7 +20,7 @@ from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calc
|
||||
from .model.config_model import Config
|
||||
from .update_checker import UpdateChecker
|
||||
from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths
|
||||
from .utils.exceptions import CaptchaEncountered
|
||||
from .utils.exceptions import CaptchaEncountered, PublishSubmissionUncertainError
|
||||
from .utils.files import abspath
|
||||
from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale
|
||||
from .utils.misc import ainput, ensure, is_frozen
|
||||
@@ -38,7 +38,10 @@ _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")
|
||||
_LOGGED_OUT_CTA_SELECTORS:Final[list[tuple["By", str]]] = [
|
||||
(By.CSS_SELECTOR, 'a[href*="einloggen"]'),
|
||||
(By.CSS_SELECTOR, 'a[href*="/m-einloggen"]'),
|
||||
]
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
@@ -997,151 +1000,250 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# No captcha detected within timeout.
|
||||
pass
|
||||
page_context = "login page" if is_login_page else "publish flow"
|
||||
LOG.debug("No captcha detected within timeout on %s", page_context)
|
||||
|
||||
async def login(self) -> None:
|
||||
sso_navigation_timeout = self._timeout("page_load")
|
||||
pre_login_gdpr_timeout = self._timeout("quick_dom")
|
||||
|
||||
LOG.info("Checking if already logged in...")
|
||||
await self.web_open(f"{self.root_url}")
|
||||
if getattr(self, "page", None) is not None:
|
||||
LOG.debug("Current page URL after opening homepage: %s", self.page.url)
|
||||
try:
|
||||
await self._click_gdpr_banner(timeout = pre_login_gdpr_timeout)
|
||||
except TimeoutError:
|
||||
LOG.debug("No GDPR banner detected before login")
|
||||
|
||||
state = await self.get_login_state(capture_diagnostics = False)
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.info("Already logged in. Skipping login.")
|
||||
return
|
||||
|
||||
LOG.debug("Navigating to SSO login page (Auth0)...")
|
||||
# m-einloggen-sso.html triggers immediate server-side redirect to Auth0
|
||||
# This avoids waiting for JS on m-einloggen.html which may not execute in headless mode
|
||||
try:
|
||||
await self.web_open(f"{self.root_url}/m-einloggen-sso.html", timeout = sso_navigation_timeout)
|
||||
except TimeoutError:
|
||||
LOG.warning("Timeout navigating to SSO login page after %.1fs", sso_navigation_timeout)
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise
|
||||
|
||||
self._login_detection_diagnostics_captured = False
|
||||
|
||||
try:
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
except (AssertionError, TimeoutError):
|
||||
# AssertionError is intentionally part of auth-boundary control flow so
|
||||
# diagnostics are captured before the original error is re-raised.
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise
|
||||
|
||||
await self._dismiss_consent_banner()
|
||||
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.info("Already logged in as [%s]. Skipping login.", self.config.login.username)
|
||||
LOG.info("Login confirmed.")
|
||||
return
|
||||
|
||||
if state == LoginState.UNKNOWN:
|
||||
LOG.warning("Login state is UNKNOWN - cannot determine if already logged in. Skipping login attempt.")
|
||||
current_url = self._current_page_url()
|
||||
LOG.warning("Login state after attempt is %s (url=%s)", state.name, current_url)
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise AssertionError(_("Login could not be confirmed after Auth0 flow (state=%s, url=%s)") % (state.name, current_url))
|
||||
|
||||
def _current_page_url(self) -> str:
|
||||
page = getattr(self, "page", None)
|
||||
if page is None:
|
||||
return "unknown"
|
||||
url = getattr(page, "url", None)
|
||||
if not isinstance(url, str) or not url:
|
||||
return "unknown"
|
||||
|
||||
parsed = urllib_parse.urlparse(url)
|
||||
host = parsed.hostname or parsed.netloc.split("@")[-1]
|
||||
netloc = f"{host}:{parsed.port}" if parsed.port is not None and host else host
|
||||
sanitized = urllib_parse.urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
|
||||
return sanitized or "unknown"
|
||||
|
||||
async def _wait_for_auth0_login_context(self) -> None:
|
||||
redirect_timeout = self._timeout("login_detection")
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: "login.kleinanzeigen.de" in self._current_page_url() or "/u/login" in self._current_page_url(),
|
||||
timeout = redirect_timeout,
|
||||
timeout_error_message = f"Auth0 redirect did not start within {redirect_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
except TimeoutError as ex:
|
||||
current_url = self._current_page_url()
|
||||
raise AssertionError(_("Auth0 redirect not detected (url=%s)") % current_url) from ex
|
||||
|
||||
async def _wait_for_auth0_password_step(self) -> None:
|
||||
password_step_timeout = self._timeout("login_detection")
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: "/u/login/password" in self._current_page_url(),
|
||||
timeout = password_step_timeout,
|
||||
timeout_error_message = f"Auth0 password page not reached within {password_step_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
except TimeoutError as ex:
|
||||
current_url = self._current_page_url()
|
||||
raise AssertionError(_("Auth0 password step not reached (url=%s)") % current_url) from ex
|
||||
|
||||
async def _wait_for_post_auth0_submit_transition(self) -> None:
|
||||
post_submit_timeout = self._timeout("login_detection")
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
fallback_max_ms = max(700, int(quick_dom_timeout * 1_000))
|
||||
fallback_min_ms = max(300, fallback_max_ms // 2)
|
||||
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: self._is_valid_post_auth0_destination(self._current_page_url()),
|
||||
timeout = post_submit_timeout,
|
||||
timeout_error_message = f"Auth0 post-submit transition did not complete within {post_submit_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
return
|
||||
except TimeoutError:
|
||||
LOG.debug("Post-submit transition not detected via URL, checking logged-in selectors")
|
||||
|
||||
login_confirmed = False
|
||||
try:
|
||||
login_confirmed = await asyncio.wait_for(self.is_logged_in(include_probe = False), timeout = post_submit_timeout)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
LOG.debug("Post-submit login verification did not complete within %.1fs", post_submit_timeout)
|
||||
|
||||
if login_confirmed:
|
||||
return
|
||||
|
||||
LOG.info("Opening login page...")
|
||||
await self.web_open(f"{self.root_url}/m-einloggen.html?targetUrl=/")
|
||||
LOG.debug("Auth0 post-submit verification remained inconclusive; applying bounded fallback pause")
|
||||
await self.web_sleep(min_ms = fallback_min_ms, max_ms = fallback_max_ms)
|
||||
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
try:
|
||||
if await asyncio.wait_for(self.is_logged_in(include_probe = False), timeout = quick_dom_timeout):
|
||||
return
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
LOG.debug("Final post-submit login confirmation did not complete within %.1fs", quick_dom_timeout)
|
||||
|
||||
# Sometimes a second login is required
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.UNKNOWN:
|
||||
LOG.warning("Login state is UNKNOWN after first login attempt - cannot determine login status. Aborting login process.")
|
||||
return
|
||||
current_url = self._current_page_url()
|
||||
raise TimeoutError(_("Auth0 post-submit verification remained inconclusive (url=%s)") % current_url)
|
||||
|
||||
if state == LoginState.LOGGED_OUT:
|
||||
LOG.debug("First login attempt did not succeed, trying second login attempt")
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
def _is_valid_post_auth0_destination(self, url:str) -> bool:
|
||||
if not url or url in {"unknown", "about:blank"}:
|
||||
return False
|
||||
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.debug("Second login attempt succeeded")
|
||||
else:
|
||||
LOG.warning("Second login attempt also failed - login may not have succeeded")
|
||||
parsed = urllib_parse.urlparse(url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = parsed.path.lower()
|
||||
|
||||
if host != "kleinanzeigen.de" and not host.endswith(".kleinanzeigen.de"):
|
||||
return False
|
||||
if host == "login.kleinanzeigen.de":
|
||||
return False
|
||||
if path.startswith("/u/login"):
|
||||
return False
|
||||
|
||||
return "error" not in path
|
||||
|
||||
async def fill_login_data_and_send(self) -> None:
|
||||
LOG.info("Logging in as [%s]...", self.config.login.username)
|
||||
await self.web_input(By.ID, "login-email", self.config.login.username)
|
||||
"""Auth0 2-step login via m-einloggen-sso.html (server-side redirect, no JS needed).
|
||||
|
||||
# clearing password input in case browser has stored login data set
|
||||
await self.web_input(By.ID, "login-password", "")
|
||||
await self.web_input(By.ID, "login-password", self.config.login.password)
|
||||
Step 1: /u/login/identifier - email
|
||||
Step 2: /u/login/password - password
|
||||
"""
|
||||
LOG.info("Logging in...")
|
||||
|
||||
await self._wait_for_auth0_login_context()
|
||||
|
||||
# Step 1: email identifier
|
||||
LOG.debug("Auth0 Step 1: entering email...")
|
||||
await self.web_input(By.ID, "username", self.config.login.username)
|
||||
await self.web_click(By.CSS_SELECTOR, "button[type='submit']")
|
||||
|
||||
# Step 2: wait for password page then enter password
|
||||
LOG.debug("Waiting for Auth0 password page...")
|
||||
await self._wait_for_auth0_password_step()
|
||||
|
||||
LOG.debug("Auth0 Step 2: entering password...")
|
||||
await self.web_input(By.CSS_SELECTOR, "input[type='password']", self.config.login.password)
|
||||
await self.check_and_wait_for_captcha(is_login_page = True)
|
||||
|
||||
await self.web_click(By.CSS_SELECTOR, "form#login-form button[type='submit']")
|
||||
await self.web_click(By.CSS_SELECTOR, "button[type='submit']")
|
||||
await self._wait_for_post_auth0_submit_transition()
|
||||
LOG.debug("Auth0 login submitted.")
|
||||
|
||||
async def handle_after_login_logic(self) -> None:
|
||||
try:
|
||||
sms_timeout = self._timeout("sms_verification")
|
||||
await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = sms_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
await self._check_sms_verification()
|
||||
except TimeoutError:
|
||||
# No SMS verification prompt detected.
|
||||
pass
|
||||
LOG.debug("No SMS verification prompt detected after login")
|
||||
|
||||
try:
|
||||
email_timeout = self._timeout("email_verification")
|
||||
await self.web_find(By.TEXT, "Um dein Konto zu schützen haben wir dir eine E-Mail geschickt", timeout = email_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
await self._check_email_verification()
|
||||
except TimeoutError:
|
||||
# No email verification prompt detected.
|
||||
pass
|
||||
LOG.debug("No email verification prompt detected after login")
|
||||
|
||||
try:
|
||||
LOG.info("Handling GDPR disclaimer...")
|
||||
gdpr_timeout = self._timeout("gdpr_prompt")
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
await self.web_click(By.ID, "gdpr-banner-cmp-button")
|
||||
await self.web_click(
|
||||
By.XPATH, "//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]", timeout = gdpr_timeout
|
||||
)
|
||||
LOG.debug("Handling GDPR disclaimer...")
|
||||
await self._click_gdpr_banner()
|
||||
except TimeoutError:
|
||||
# GDPR banner not shown within timeout.
|
||||
pass
|
||||
LOG.debug("GDPR banner not found or timed out")
|
||||
|
||||
async def _auth_probe_login_state(self) -> LoginState:
|
||||
"""Probe an auth-required endpoint to classify login state.
|
||||
async def _check_sms_verification(self) -> None:
|
||||
sms_timeout = self._timeout("sms_verification")
|
||||
await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = sms_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
|
||||
The probe is non-mutating (GET request). It is used as a fallback method by
|
||||
get_login_state() when DOM-based checks are inconclusive.
|
||||
async def _dismiss_consent_banner(self) -> None:
|
||||
"""Dismiss the GDPR/TCF consent banner if it is present.
|
||||
|
||||
This banner can appear on any page navigation (not just after login) and blocks
|
||||
all form interaction until dismissed. Uses a short timeout to avoid slowing down
|
||||
the flow when the banner is already gone.
|
||||
"""
|
||||
|
||||
url = f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"
|
||||
try:
|
||||
response = await self.web_request(url, valid_response_codes = [200, 401, 403])
|
||||
except (TimeoutError, AssertionError):
|
||||
# AssertionError can occur when web_request() fails to parse the response (e.g., unexpected content type)
|
||||
# Treat both timeout and assertion failures as UNKNOWN to avoid false assumptions about login state
|
||||
return LoginState.UNKNOWN
|
||||
banner_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = banner_timeout)
|
||||
LOG.debug("Consent banner detected, clicking 'Alle akzeptieren'...")
|
||||
await self.web_click(By.ID, "gdpr-banner-accept")
|
||||
except TimeoutError:
|
||||
LOG.debug("Consent banner not present; continuing without dismissal")
|
||||
|
||||
status_code = response.get("statusCode")
|
||||
if status_code in {401, 403}:
|
||||
return LoginState.LOGGED_OUT
|
||||
async def _check_email_verification(self) -> None:
|
||||
email_timeout = self._timeout("email_verification")
|
||||
await self.web_find(By.TEXT, "Um dein Konto zu schützen haben wir dir eine E-Mail geschickt", timeout = email_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
|
||||
content = response.get("content", "")
|
||||
if not isinstance(content, str):
|
||||
return LoginState.UNKNOWN
|
||||
async def _click_gdpr_banner(self, *, timeout:float | None = None) -> None:
|
||||
gdpr_timeout = self._timeout("quick_dom") if timeout is None else timeout
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
await self.web_click(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
lowered = content.lower()
|
||||
if "m-einloggen" in lowered or "login-email" in lowered or "login-password" in lowered or "login-form" in lowered:
|
||||
return LoginState.LOGGED_OUT
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
if isinstance(payload, dict) and "ads" in payload:
|
||||
return LoginState.LOGGED_IN
|
||||
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
async def get_login_state(self) -> LoginState:
|
||||
"""Determine current login state using layered detection.
|
||||
async def get_login_state(self, *, capture_diagnostics:bool = True) -> LoginState:
|
||||
"""Determine current login state using DOM - first detection.
|
||||
|
||||
Order:
|
||||
1) DOM-based check via `is_logged_in(include_probe=False)` (preferred - stealthy)
|
||||
2) Server-side auth probe via `_auth_probe_login_state` (fallback - more reliable)
|
||||
3) If still inconclusive, capture diagnostics via
|
||||
`_capture_login_detection_diagnostics_if_enabled` and return `UNKNOWN`
|
||||
1) DOM - based logged - in check via `is_logged_in(include_probe=False)`
|
||||
2) Logged - out CTA check
|
||||
3) If inconclusive, optionally capture diagnostics and return `UNKNOWN`
|
||||
"""
|
||||
# Prefer DOM-based checks first to minimize bot-like behavior.
|
||||
# The auth probe makes a JSON API request that normal users wouldn't trigger.
|
||||
# Prefer DOM-based checks first to minimize bot-like behavior and avoid
|
||||
# fragile API probing side effects. Server-side auth probing was removed.
|
||||
if await self.is_logged_in(include_probe = False):
|
||||
return LoginState.LOGGED_IN
|
||||
|
||||
# Fall back to the more reliable server-side auth probe.
|
||||
# SPA/hydration delays can cause DOM-based checks to temporarily miss login indicators.
|
||||
state = await self._auth_probe_login_state()
|
||||
if state != LoginState.UNKNOWN:
|
||||
return state
|
||||
if await self._has_logged_out_cta(log_timeout = False):
|
||||
return LoginState.LOGGED_OUT
|
||||
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
if capture_diagnostics:
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
def _diagnostics_output_dir(self) -> Path:
|
||||
@@ -1254,8 +1356,27 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
login_check_timeout,
|
||||
effective_timeout,
|
||||
)
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
tried_login_selectors = _format_login_detection_selectors(_LOGIN_DETECTION_SELECTORS)
|
||||
|
||||
try:
|
||||
user_info, matched_selector = await self.web_text_first_available(
|
||||
_LOGIN_DETECTION_SELECTORS,
|
||||
timeout = quick_dom_timeout,
|
||||
key = "quick_dom",
|
||||
description = "login_detection(quick_logged_in)",
|
||||
)
|
||||
if username in user_info.lower():
|
||||
matched_selector_display = (
|
||||
f"{_LOGIN_DETECTION_SELECTORS[matched_selector][0].name}={_LOGIN_DETECTION_SELECTORS[matched_selector][1]}"
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTORS)
|
||||
else f"selector_index_{matched_selector}"
|
||||
)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_display)
|
||||
return True
|
||||
except TimeoutError:
|
||||
LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors)
|
||||
|
||||
try:
|
||||
user_info, matched_selector = await self.web_text_first_available(
|
||||
_LOGIN_DETECTION_SELECTORS,
|
||||
@@ -1264,29 +1385,57 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
description = "login_detection(selector_group)",
|
||||
)
|
||||
if username in user_info.lower():
|
||||
matched_selector_label = (
|
||||
_LOGIN_DETECTION_SELECTOR_LABELS[matched_selector]
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTOR_LABELS)
|
||||
matched_selector_display = (
|
||||
f"{_LOGIN_DETECTION_SELECTORS[matched_selector][0].name}={_LOGIN_DETECTION_SELECTORS[matched_selector][1]}"
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTORS)
|
||||
else f"selector_index_{matched_selector}"
|
||||
)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_label)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_display)
|
||||
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 via configured login detection selectors (%s)", tried_login_selectors)
|
||||
if await self._has_logged_out_cta():
|
||||
return False
|
||||
|
||||
state = await self._auth_probe_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
return True
|
||||
if include_probe:
|
||||
LOG.debug("No login detected via configured login detection selectors (%s); auth probe is disabled", tried_login_selectors)
|
||||
return False
|
||||
|
||||
LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors)
|
||||
return False
|
||||
|
||||
async def _has_logged_out_cta(self, *, log_timeout:bool = True) -> bool:
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
tried_logged_out_selectors = _format_login_detection_selectors(_LOGGED_OUT_CTA_SELECTORS)
|
||||
|
||||
try:
|
||||
cta_element, cta_index = await self.web_find_first_available(
|
||||
_LOGGED_OUT_CTA_SELECTORS,
|
||||
timeout = quick_dom_timeout,
|
||||
key = "quick_dom",
|
||||
description = "login_detection(logged_out_cta)",
|
||||
)
|
||||
cta_text = await self._extract_visible_text(cta_element)
|
||||
if cta_text.strip():
|
||||
matched_selector_display = (
|
||||
f"{_LOGGED_OUT_CTA_SELECTORS[cta_index][0].name}={_LOGGED_OUT_CTA_SELECTORS[cta_index][1]}"
|
||||
if 0 <= cta_index < len(_LOGGED_OUT_CTA_SELECTORS)
|
||||
else f"selector_index_{cta_index}"
|
||||
)
|
||||
if 0 <= cta_index < len(_LOGGED_OUT_CTA_SELECTORS):
|
||||
LOG.debug("Fast logged-out pre-check matched selector '%s'", matched_selector_display)
|
||||
return True
|
||||
LOG.debug("Fast logged-out pre-check got unexpected selector index '%s'; failing closed", cta_index)
|
||||
return False
|
||||
except TimeoutError:
|
||||
if log_timeout:
|
||||
LOG.debug(
|
||||
"Fast logged-out pre-check found no login CTA (%s) within %.1fs",
|
||||
tried_logged_out_selectors,
|
||||
quick_dom_timeout,
|
||||
)
|
||||
|
||||
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]]:
|
||||
@@ -1309,13 +1458,25 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
try:
|
||||
response = await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum={page}")
|
||||
except TimeoutError as ex:
|
||||
LOG.warning("Pagination request timed out on page %s: %s", page, ex)
|
||||
LOG.warning("Pagination request failed on page %s: %s", page, ex)
|
||||
break
|
||||
|
||||
if not isinstance(response, dict):
|
||||
LOG.warning("Unexpected pagination response type on page %s: %s", page, type(response).__name__)
|
||||
break
|
||||
|
||||
content = response.get("content", "")
|
||||
if isinstance(content, bytearray):
|
||||
content = bytes(content)
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8", errors = "replace")
|
||||
if not isinstance(content, str):
|
||||
LOG.warning("Unexpected response content type on page %s: %s", page, type(content).__name__)
|
||||
break
|
||||
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as ex:
|
||||
except (json.JSONDecodeError, TypeError) as ex:
|
||||
if not content:
|
||||
LOG.warning("Empty JSON response content on page %s", page)
|
||||
break
|
||||
@@ -1336,7 +1497,24 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
LOG.warning("Unexpected 'ads' type on page %s: %s value: %s", page, type(page_ads).__name__, preview)
|
||||
break
|
||||
|
||||
ads.extend(page_ads)
|
||||
filtered_page_ads:list[dict[str, Any]] = []
|
||||
rejected_count = 0
|
||||
rejected_preview:str | None = None
|
||||
for entry in page_ads:
|
||||
if isinstance(entry, dict) and "id" in entry and "state" in entry:
|
||||
filtered_page_ads.append(entry)
|
||||
continue
|
||||
rejected_count += 1
|
||||
if rejected_preview is None:
|
||||
rejected_preview = repr(entry)
|
||||
|
||||
if rejected_count > 0:
|
||||
preview = rejected_preview or "<none>"
|
||||
if len(preview) > SNIPPET_LIMIT:
|
||||
preview = preview[:SNIPPET_LIMIT] + "..."
|
||||
LOG.warning("Filtered %s malformed ad entries on page %s (sample: %s)", rejected_count, page, preview)
|
||||
|
||||
ads.extend(filtered_page_ads)
|
||||
|
||||
paging = json_data.get("paging")
|
||||
if not isinstance(paging, dict):
|
||||
@@ -1554,7 +1732,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
count += 1
|
||||
success = False
|
||||
|
||||
# Retry loop only for publish_ad (before submission completes)
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
await self.publish_ad(ad_file, ad_cfg, ad_cfg_orig, published_ads, AdUpdateStrategy.REPLACE)
|
||||
@@ -1562,14 +1739,31 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
break # Publish succeeded, exit retry loop
|
||||
except asyncio.CancelledError:
|
||||
raise # Respect task cancellation
|
||||
except PublishSubmissionUncertainError as ex:
|
||||
await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex)
|
||||
LOG.warning(
|
||||
"Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.",
|
||||
attempt,
|
||||
max_retries,
|
||||
ad_cfg.title,
|
||||
ex,
|
||||
)
|
||||
LOG.warning("Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.", ad_cfg.title)
|
||||
LOG.warning(
|
||||
"If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads=<id>'; "
|
||||
"otherwise rerun publish for this ad."
|
||||
)
|
||||
failed_count += 1
|
||||
break
|
||||
except (TimeoutError, ProtocolException) as ex:
|
||||
await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex)
|
||||
if attempt < max_retries:
|
||||
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
||||
await self.web_sleep(2) # Wait before retry
|
||||
else:
|
||||
if attempt >= max_retries:
|
||||
LOG.error("All %s attempts failed for '%s': %s. Skipping ad.", max_retries, ad_cfg.title, ex)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
||||
await self.web_sleep(2_000) # Wait before retry
|
||||
|
||||
# Check publishing result separately (no retry - ad is already submitted)
|
||||
if success:
|
||||
@@ -1593,10 +1787,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], published_ads:list[dict[str, Any]], mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE
|
||||
) -> None:
|
||||
"""
|
||||
@param ad_cfg: the effective ad config (i.e. with default values applied etc.)
|
||||
@param ad_cfg_orig: the ad config as present in the YAML file
|
||||
@param published_ads: json list of published ads
|
||||
@param mode: the mode of ad editing, either publishing a new or updating an existing ad
|
||||
@ param ad_cfg: the effective ad config(i.e. with default values applied etc.)
|
||||
@ param ad_cfg_orig: the ad config as present in the YAML file
|
||||
@ param published_ads: json list of published ads
|
||||
@ param mode: the mode of ad editing, either publishing a new or updating an existing ad
|
||||
"""
|
||||
|
||||
if mode == AdUpdateStrategy.REPLACE:
|
||||
@@ -1613,6 +1807,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
LOG.info("Updating ad '%s'...", ad_cfg.title)
|
||||
await self.web_open(f"{self.root_url}/p-anzeige-bearbeiten.html?adId={ad_cfg.id}")
|
||||
|
||||
await self._dismiss_consent_banner()
|
||||
|
||||
if loggers.is_debug(LOG):
|
||||
LOG.debug(" -> effective ad meta:")
|
||||
YAML().dump(ad_cfg.model_dump(), sys.stdout)
|
||||
@@ -1718,39 +1914,42 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
# submit
|
||||
#############################
|
||||
try:
|
||||
await self.web_click(By.ID, "pstad-submit")
|
||||
except TimeoutError:
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40
|
||||
await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]")
|
||||
await self.web_click(By.ID, "imprint-guidance-submit")
|
||||
try:
|
||||
await self.web_click(By.ID, "pstad-submit")
|
||||
except TimeoutError:
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40
|
||||
await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]")
|
||||
await self.web_click(By.ID, "imprint-guidance-submit")
|
||||
|
||||
# check for no image question
|
||||
try:
|
||||
image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]'
|
||||
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
||||
await self.web_click(By.XPATH, image_hint_xpath)
|
||||
except TimeoutError:
|
||||
# Image hint not shown; continue publish flow.
|
||||
pass # nosec
|
||||
# check for no image question
|
||||
try:
|
||||
image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]'
|
||||
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
||||
await self.web_click(By.XPATH, image_hint_xpath)
|
||||
except TimeoutError:
|
||||
# Image hint not shown; continue publish flow.
|
||||
pass # nosec
|
||||
|
||||
#############################
|
||||
# wait for payment form if commercial account is used
|
||||
#############################
|
||||
try:
|
||||
short_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout)
|
||||
#############################
|
||||
# wait for payment form if commercial account is used
|
||||
#############################
|
||||
try:
|
||||
short_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout)
|
||||
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Payment form detected! Please proceed with payment.")
|
||||
LOG.warning("############################################")
|
||||
await self.web_scroll_page_down()
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# Payment form not present.
|
||||
pass
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Payment form detected! Please proceed with payment.")
|
||||
LOG.warning("############################################")
|
||||
await self.web_scroll_page_down()
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# Payment form not present.
|
||||
pass
|
||||
|
||||
confirmation_timeout = self._timeout("publishing_confirmation")
|
||||
await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout)
|
||||
confirmation_timeout = self._timeout("publishing_confirmation")
|
||||
await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout)
|
||||
except (TimeoutError, ProtocolException) as ex:
|
||||
raise PublishSubmissionUncertainError("submission may have succeeded before failure") from ex
|
||||
|
||||
# extract the ad id from the URL's query parameter
|
||||
current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(self.page.url).query)
|
||||
@@ -2033,11 +2232,17 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
await self.__set_shipping_options(ad_cfg, mode)
|
||||
else:
|
||||
special_shipping_selector = '//select[contains(@id, ".versand_s")]'
|
||||
if await self.web_check(By.XPATH, special_shipping_selector, Is.DISPLAYED):
|
||||
# try to set special attribute selector (then we have a commercial account)
|
||||
is_commercial_shipping = False
|
||||
try:
|
||||
has_commercial_selector = await self.web_check(By.XPATH, special_shipping_selector, Is.DISPLAYED, timeout = short_timeout)
|
||||
except TimeoutError:
|
||||
# Element does not exist in DOM (non-commercial account or UI change); fall through to dialog-based shipping.
|
||||
has_commercial_selector = False
|
||||
if has_commercial_selector:
|
||||
shipping_value = "ja" if ad_cfg.shipping_type == "SHIPPING" else "nein"
|
||||
await self.web_select(By.XPATH, special_shipping_selector, shipping_value)
|
||||
else:
|
||||
is_commercial_shipping = True
|
||||
if not is_commercial_shipping:
|
||||
try:
|
||||
# no options. only costs. Set custom shipping cost
|
||||
await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]')
|
||||
@@ -2201,7 +2406,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
async def download_ads(self) -> None:
|
||||
"""
|
||||
Determines which download mode was chosen with the arguments, and calls the specified download routine.
|
||||
This downloads either all, only unsaved (new), or specific ads given by ID.
|
||||
This downloads either all, only unsaved(new), or specific ads given by ID.
|
||||
"""
|
||||
# Fetch published ads once from manage-ads JSON to avoid repetitive API calls during extraction
|
||||
# Build lookup dict inline and pass directly to extractor (no cache abstraction needed)
|
||||
@@ -2290,10 +2495,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
def __get_description(self, ad_cfg:Ad, *, with_affixes:bool) -> str:
|
||||
"""Get the ad description optionally with prefix and suffix applied.
|
||||
|
||||
Precedence (highest to lowest):
|
||||
1. Direct ad-level affixes (description_prefix/suffix)
|
||||
2. Global flattened affixes (ad_defaults.description_prefix/suffix)
|
||||
3. Legacy global nested affixes (ad_defaults.description.prefix/suffix)
|
||||
Precedence(highest to lowest):
|
||||
1. Direct ad - level affixes(description_prefix / suffix)
|
||||
2. Global flattened affixes(ad_defaults.description_prefix / suffix)
|
||||
3. Legacy global nested affixes(ad_defaults.description.prefix / suffix)
|
||||
|
||||
Args:
|
||||
ad_cfg: The ad configuration dictionary
|
||||
@@ -2365,8 +2570,8 @@ def main(args:list[str]) -> None:
|
||||
print(
|
||||
textwrap.dedent(rf"""
|
||||
_ _ _ _ _ _
|
||||
| | _| | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_
|
||||
| |/ / |/ _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __|
|
||||
| | _ | | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_
|
||||
| | / / | / _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __|
|
||||
| <| | __/ | | | | (_| | | | |/ / __/ | (_| | __/ | | |____| |_) | (_) | |_
|
||||
|_|\_\_|\___|_|_| |_|\__,_|_| |_/___\___|_|\__, |\___|_| |_| |_.__/ \___/ \__|
|
||||
|___/
|
||||
|
||||
@@ -37,9 +37,12 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Empty JSON response content on page %s": "Leerer JSON-Antwortinhalt auf Seite %s"
|
||||
"Failed to parse JSON response on page %s: %s (content: %s)": "Fehler beim Parsen der JSON-Antwort auf Seite %s: %s (Inhalt: %s)"
|
||||
"Stopping pagination after %s pages to avoid infinite loop": "Stoppe die Seitenaufschaltung nach %s Seiten, um eine Endlosschleife zu vermeiden"
|
||||
"Pagination request timed out on page %s: %s": "Zeitueberschreitung bei der Seitenabfrage auf Seite %s: %s"
|
||||
"Pagination request failed on page %s: %s": "Seitenabfrage auf Seite %s fehlgeschlagen: %s"
|
||||
"Unexpected pagination response type on page %s: %s": "Unerwarteter Typ der Paginierungsantwort auf Seite %s: %s"
|
||||
"Unexpected response content type on page %s: %s": "Unerwarteter Antwortinhalt-Typ auf Seite %s: %s"
|
||||
"Unexpected JSON payload on page %s (content: %s)": "Unerwartete JSON-Antwort auf Seite %s (Inhalt: %s)"
|
||||
"Unexpected 'ads' type on page %s: %s value: %s": "Unerwarteter 'ads'-Typ auf Seite %s: %s Wert: %s"
|
||||
"Filtered %s malformed ad entries on page %s (sample: %s)": "%s fehlerhafte Anzeigen-Einträge auf Seite %s gefiltert (Beispiel: %s)"
|
||||
"Reached last page %s of %s, stopping pagination": "Letzte Seite %s von %s erreicht, beende Paginierung"
|
||||
"No ads found on page %s, stopping pagination": "Keine Anzeigen auf Seite %s gefunden, beende Paginierung"
|
||||
"Invalid 'next' page value in paging info: %s, stopping pagination": "Ungültiger 'next'-Seitenwert in Paginierungsinfo: %s, beende Paginierung"
|
||||
@@ -86,14 +89,36 @@ kleinanzeigen_bot/__init__.py:
|
||||
|
||||
login:
|
||||
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
||||
"Current page URL after opening homepage: %s": "Aktuelle Seiten-URL nach dem Öffnen der Startseite: %s"
|
||||
"Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung."
|
||||
"Opening login page...": "Öffne Anmeldeseite..."
|
||||
"Login state is UNKNOWN - cannot determine if already logged in. Skipping login attempt.": "Login-Status ist UNKNOWN - kann nicht bestimmt werden, ob bereits eingeloggt ist. Überspringe Anmeldeversuch."
|
||||
"Login state is UNKNOWN after first login attempt - cannot determine login status. Aborting login process.": "Login-Status ist UNKNOWN nach dem ersten Anmeldeversuch - kann Login-Status nicht bestimmen. Breche Anmeldeprozess ab."
|
||||
"First login attempt did not succeed, trying second login attempt": "Erster Anmeldeversuch war nicht erfolgreich, versuche zweiten Anmeldeversuch"
|
||||
"Second login attempt succeeded": "Zweiter Anmeldeversuch erfolgreich"
|
||||
"Second login attempt also failed - login may not have succeeded": "Zweiter Anmeldeversuch ebenfalls fehlgeschlagen - Anmeldung möglicherweise nicht erfolgreich"
|
||||
"Already logged in. Skipping login.": "Bereits eingeloggt. Überspringe Anmeldung."
|
||||
"Navigating to SSO login page (Auth0)...": "Navigiere zur SSO-Anmeldeseite (Auth0)..."
|
||||
"Timeout navigating to SSO login page after %.1fs": "Zeitüberschreitung beim Navigieren zur SSO-Anmeldeseite nach %.1fs"
|
||||
"Login confirmed.": "Anmeldung bestätigt."
|
||||
"Login state after attempt is %s (url=%s)": "Login-Status nach dem Versuch ist %s (URL=%s)"
|
||||
"Login could not be confirmed after Auth0 flow (state=%s, url=%s)": "Anmeldung nach Auth0-Flow konnte nicht bestätigt werden (Status=%s, URL=%s)"
|
||||
|
||||
_wait_for_auth0_login_context:
|
||||
"Auth0 redirect not detected (url=%s)": "Auth0-Weiterleitung nicht erkannt (URL=%s)"
|
||||
|
||||
_wait_for_auth0_password_step:
|
||||
"Auth0 password step not reached (url=%s)": "Auth0-Passwortschritt nicht erreicht (URL=%s)"
|
||||
|
||||
_wait_for_post_auth0_submit_transition:
|
||||
"Auth0 post-submit verification remained inconclusive (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar (URL=%s)"
|
||||
|
||||
fill_login_data_and_send:
|
||||
"Logging in...": "Anmeldung..."
|
||||
"Auth0 Step 1: entering email...": "Auth0 Schritt 1: E-Mail wird eingegeben..."
|
||||
"Waiting for Auth0 password page...": "Warte auf Auth0-Passwortseite..."
|
||||
"Auth0 Step 2: entering password...": "Auth0 Schritt 2: Passwort wird eingegeben..."
|
||||
"Auth0 login submitted.": "Auth0-Anmeldung abgesendet."
|
||||
|
||||
_check_sms_verification:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
|
||||
_check_email_verification:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
|
||||
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)"
|
||||
@@ -101,8 +126,6 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Timeout waiting for login detection selector group after %.1fs": "Timeout beim Warten auf die Login-Erkennungs-Selektorgruppe nach %.1fs"
|
||||
|
||||
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."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
"Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..."
|
||||
|
||||
delete_ads:
|
||||
@@ -154,10 +177,15 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist"
|
||||
" -> Could not confirm publishing for '%s', but ad may be online": " -> Veröffentlichung für '%s' konnte nicht bestätigt werden, aber Anzeige ist möglicherweise online"
|
||||
"Attempt %s/%s failed for '%s': %s. Retrying...": "Versuch %s/%s fehlgeschlagen für '%s': %s. Erneuter Versuch..."
|
||||
"Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.": "Versuch %s/%s für '%s' hat die Submit-Grenze erreicht, ist aber fehlgeschlagen: %s. Kein erneuter Versuch, um doppelte Anzeigen zu vermeiden."
|
||||
"Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.": "Manuelle Wiederherstellung für '%s' erforderlich. Prüfen Sie in 'Meine Anzeigen', ob die Anzeige veröffentlicht wurde."
|
||||
? "If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads=<id>'; otherwise rerun publish for this ad."
|
||||
: "Falls veröffentlicht, lokalen Stand mit 'kleinanzeigen-bot download --ads=new' oder 'kleinanzeigen-bot download --ads=<id>' synchronisieren; andernfalls Veröffentlichung für diese Anzeige erneut starten."
|
||||
"All %s attempts failed for '%s': %s. Skipping ad.": "Alle %s Versuche fehlgeschlagen für '%s': %s. Überspringe Anzeige."
|
||||
"DONE: (Re-)published %s (%s failed after retries)": "FERTIG: %s (erneut) veröffentlicht (%s fehlgeschlagen nach Wiederholungen)"
|
||||
"DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht"
|
||||
"ad": "Anzeige"
|
||||
|
||||
apply_auto_price_reduction:
|
||||
"Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert."
|
||||
"Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten."
|
||||
@@ -261,9 +289,6 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Unknown command: %s": "Unbekannter Befehl: %s"
|
||||
"Timing collector flush failed: %s": "Zeitmessdaten konnten nicht gespeichert werden: %s"
|
||||
|
||||
fill_login_data_and_send:
|
||||
"Logging in as [%s]...": "Anmeldung als [%s]..."
|
||||
|
||||
__set_shipping:
|
||||
"Unable to close shipping dialog!": "Versanddialog konnte nicht geschlossen werden!"
|
||||
|
||||
|
||||
@@ -14,3 +14,10 @@ class CaptchaEncountered(KleinanzeigenBotError):
|
||||
def __init__(self, restart_delay:timedelta) -> None:
|
||||
super().__init__()
|
||||
self.restart_delay = restart_delay
|
||||
|
||||
|
||||
class PublishSubmissionUncertainError(KleinanzeigenBotError):
|
||||
"""Raised when publish submission may have reached the server state boundary."""
|
||||
|
||||
def __init__(self, reason:str) -> None:
|
||||
super().__init__(reason)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||
import asyncio, copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import timedelta
|
||||
@@ -10,6 +10,7 @@ from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from nodriver.core.connection import ProtocolException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, KleinanzeigenBot, LoginState, misc
|
||||
@@ -17,6 +18,7 @@ from kleinanzeigen_bot._version import __version__
|
||||
from kleinanzeigen_bot.model.ad_model import Ad
|
||||
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig
|
||||
from kleinanzeigen_bot.utils import dicts, loggers, xdg_paths
|
||||
from kleinanzeigen_bot.utils.exceptions import PublishSubmissionUncertainError
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
|
||||
|
||||
|
||||
@@ -442,7 +444,12 @@ class TestKleinanzeigenBotAuthentication:
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_returns_true_when_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login check returns true when logged in."""
|
||||
with patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)):
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("Welcome dummy_user", 0),
|
||||
):
|
||||
assert await test_bot.is_logged_in() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -460,45 +467,96 @@ class TestKleinanzeigenBotAuthentication:
|
||||
async def test_is_logged_in_returns_false_when_not_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login check returns false when not logged in."""
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_request",
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {"statusCode": 200, "content": "<html><a href='/m-einloggen.html'>login</a></html>"},
|
||||
side_effect = [("nicht-eingeloggt", 0), ("kein user signal", 0)],
|
||||
),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
assert await test_bot.is_logged_in() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_uses_selector_group_timeout_key(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify login detection uses selector-group lookup with login_detection timeout key."""
|
||||
with patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited_once()
|
||||
call_args = group_text.await_args
|
||||
assert call_args is not None
|
||||
assert call_args.args[0] == [(By.CLASS_NAME, "mr-medium"), (By.ID, "user-email")]
|
||||
assert call_args.kwargs["key"] == "login_detection"
|
||||
assert call_args.kwargs["timeout"] == test_bot._timeout("login_detection")
|
||||
async def test_has_logged_out_cta_requires_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = ""),
|
||||
):
|
||||
assert await test_bot._has_logged_out_cta() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_selector_label_without_raw_selector_literals(
|
||||
async def test_has_logged_out_cta_accepts_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Einloggen"),
|
||||
):
|
||||
assert await test_bot._has_logged_out_cta() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_uses_selector_group_timeout_key(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify login detection uses selector-group lookup with login_detection timeout key."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert any(call.kwargs.get("timeout") == test_bot._timeout("login_detection") for call in group_text.await_args_list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_runs_full_selector_group_before_cta_precheck(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Quick CTA checks must not short-circuit before full logged-in selector checks."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert group_text.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_short_circuits_before_cta_check_when_quick_user_signal_matches(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Logged-in quick pre-check should win even if incidental login links exist elsewhere."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("angemeldet als: dummy_user", 0),
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert group_text.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_matched_raw_selector(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Login detection logs should reference stable labels, not raw selector values."""
|
||||
"""Login detection logs should show the matched raw selector."""
|
||||
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)),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("angemeldet als: dummy_user", 0),
|
||||
),
|
||||
):
|
||||
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
|
||||
assert "Login detected via login detection selector" in caplog.text
|
||||
assert "CLASS_NAME=mr-medium" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_generic_message_when_selector_group_does_not_match(
|
||||
@@ -509,78 +567,87 @@ class TestKleinanzeigenBotAuthentication:
|
||||
|
||||
with (
|
||||
caplog.at_level("DEBUG"),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
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
|
||||
)
|
||||
assert "No login detected via configured login detection selectors" in caplog.text
|
||||
assert "CLASS_NAME=mr-medium" in caplog.text
|
||||
assert "ID=user-email" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_raw_selectors_when_probe_reports_logged_out(
|
||||
async def test_is_logged_in_logs_raw_selectors_when_dom_checks_fail_and_probe_disabled(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Probe-based final failure should include the tried raw selectors for debugging."""
|
||||
"""Final failure should report selectors and disabled-probe state."""
|
||||
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),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
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
|
||||
)
|
||||
assert "No login detected via configured login detection selectors" in caplog.text
|
||||
assert "auth probe is disabled" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_prefers_dom_over_auth_probe(self, test_bot:KleinanzeigenBot) -> None:
|
||||
async def test_get_login_state_prefers_dom_checks(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)) as web_text,
|
||||
patch.object(
|
||||
test_bot, "_auth_probe_login_state", new_callable = AsyncMock, side_effect = AssertionError("Probe must not run when DOM is deterministic")
|
||||
) as probe,
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("Welcome dummy_user", 0),
|
||||
) as web_text,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_IN
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_current_page_url_strips_query_and_fragment(self, test_bot:KleinanzeigenBot) -> None:
|
||||
page = MagicMock()
|
||||
page.url = "https://login.kleinanzeigen.de/u/login/password?state=secret&code=abc#frag"
|
||||
test_bot.page = page
|
||||
|
||||
assert test_bot._current_page_url() == "https://login.kleinanzeigen.de/u/login/password"
|
||||
|
||||
def test_is_valid_post_auth0_destination_filters_invalid_urls(self, test_bot:KleinanzeigenBot) -> None:
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/m-meine-anzeigen.html") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("https://foo.kleinanzeigen.de/") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("unknown") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("about:blank") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://evilkleinanzeigen.de/") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://kleinanzeigen.de.evil.com/") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://login.kleinanzeigen.de/u/login/password") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/login-error-500") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_falls_back_to_auth_probe_when_dom_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
async def test_get_login_state_returns_unknown_when_dom_checks_are_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_IN) as probe,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_IN
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_falls_back_to_auth_probe_when_dom_logged_out(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT) as probe,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_OUT
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_returns_unknown_when_probe_unknown_and_dom_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN) as probe,
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]) as web_text,
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()) as cta_find,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
probe.assert_awaited_once()
|
||||
web_text.assert_awaited_once()
|
||||
assert web_text.await_count == 2
|
||||
assert cta_find.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_returns_logged_out_when_cta_detected(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
side_effect = [TimeoutError(), TimeoutError()],
|
||||
) as web_text,
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Hier einloggen"),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_OUT
|
||||
assert web_text.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_unknown_captures_diagnostics_when_enabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
@@ -592,8 +659,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
test_bot.page = page
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
|
||||
@@ -610,8 +677,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
test_bot.page = page
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
|
||||
@@ -633,8 +700,21 @@ class TestKleinanzeigenBotAuthentication:
|
||||
stdin_mock.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
side_effect = [
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
],
|
||||
),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
):
|
||||
@@ -661,8 +741,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
stdin_mock.isatty.return_value = False
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
):
|
||||
@@ -676,65 +756,71 @@ class TestKleinanzeigenBotAuthentication:
|
||||
with (
|
||||
patch.object(test_bot, "web_open") as mock_open,
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, side_effect = [LoginState.LOGGED_OUT, LoginState.LOGGED_IN]) as mock_logged_in,
|
||||
patch.object(test_bot, "web_find", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
):
|
||||
await test_bot.login()
|
||||
|
||||
mock_open.assert_called()
|
||||
mock_logged_in.assert_called()
|
||||
mock_input.assert_called()
|
||||
mock_click.assert_called()
|
||||
opened_urls = [call.args[0] for call in mock_open.call_args_list]
|
||||
assert any(url.startswith(test_bot.root_url) for url in opened_urls)
|
||||
assert any(url.endswith("/m-einloggen-sso.html") for url in opened_urls)
|
||||
mock_logged_in.assert_awaited()
|
||||
mock_fill.assert_awaited_once()
|
||||
mock_after_login.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_handles_captcha(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login flow handles captcha correctly."""
|
||||
async def test_login_flow_returns_early_when_already_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Login should return early when state is already LOGGED_IN."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open"),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"get_login_state",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [LoginState.LOGGED_OUT, LoginState.LOGGED_OUT, LoginState.LOGGED_IN],
|
||||
),
|
||||
patch.object(test_bot, "web_find") as mock_find,
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
patch.object(test_bot, "web_open") as mock_open,
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_IN) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
|
||||
):
|
||||
# Mock the sequence of web_find calls:
|
||||
# First login attempt:
|
||||
# 1. Captcha iframe found (in check_and_wait_for_captcha)
|
||||
# 2. Phone verification not found (in handle_after_login_logic)
|
||||
# 3. Email verification not found (in handle_after_login_logic)
|
||||
# 4. GDPR banner not found (in handle_after_login_logic)
|
||||
# Second login attempt:
|
||||
# 5. Captcha iframe found (in check_and_wait_for_captcha)
|
||||
# 6. Phone verification not found (in handle_after_login_logic)
|
||||
# 7. Email verification not found (in handle_after_login_logic)
|
||||
# 8. GDPR banner not found (in handle_after_login_logic)
|
||||
mock_find.side_effect = [
|
||||
AsyncMock(), # Captcha iframe (first login)
|
||||
TimeoutError(), # Phone verification (first login)
|
||||
TimeoutError(), # Email verification (first login)
|
||||
TimeoutError(), # GDPR banner (first login)
|
||||
AsyncMock(), # Captcha iframe (second login)
|
||||
TimeoutError(), # Phone verification (second login)
|
||||
TimeoutError(), # Email verification (second login)
|
||||
TimeoutError(), # GDPR banner (second login)
|
||||
]
|
||||
mock_ainput.return_value = ""
|
||||
mock_input.return_value = AsyncMock()
|
||||
mock_click.return_value = AsyncMock()
|
||||
|
||||
await test_bot.login()
|
||||
|
||||
# Verify the complete flow
|
||||
assert mock_find.call_count == 8 # Exactly 8 web_find calls
|
||||
assert mock_ainput.call_count == 2 # Two captcha prompts
|
||||
assert mock_input.call_count == 6 # Two login attempts with username, clear password, and set password
|
||||
assert mock_click.call_count == 2 # Two submit button clicks
|
||||
mock_open.assert_awaited_once()
|
||||
assert mock_open.await_args is not None
|
||||
assert mock_open.await_args.args[0] == test_bot.root_url
|
||||
mock_state.assert_awaited_once()
|
||||
mock_fill.assert_not_called()
|
||||
mock_after_login.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_raises_when_state_remains_unknown(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Post-login UNKNOWN state should fail fast with diagnostics."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open"),
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, side_effect = [LoginState.LOGGED_OUT, LoginState.UNKNOWN]) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
|
||||
):
|
||||
with pytest.raises(AssertionError, match = "Login could not be confirmed"):
|
||||
await test_bot.login()
|
||||
|
||||
mock_diagnostics.assert_awaited_once()
|
||||
mock_state.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_raises_when_sso_navigation_times_out(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""SSO navigation timeout should trigger diagnostics and re-raise."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock, side_effect = [None, TimeoutError("sso timeout")]),
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "sso timeout"):
|
||||
await test_bot.login()
|
||||
|
||||
mock_diagnostics.assert_awaited_once()
|
||||
mock_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None:
|
||||
@@ -762,62 +848,142 @@ class TestKleinanzeigenBotAuthentication:
|
||||
async def test_fill_login_data_and_send(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login form filling works correctly."""
|
||||
with (
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock) as wait_context,
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock) as wait_password,
|
||||
patch.object(test_bot, "_wait_for_post_auth0_submit_transition", new_callable = AsyncMock) as wait_transition,
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha,
|
||||
):
|
||||
# Mock successful login form interaction
|
||||
mock_input.return_value = AsyncMock()
|
||||
mock_click.return_value = AsyncMock()
|
||||
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
wait_context.assert_awaited_once()
|
||||
wait_password.assert_awaited_once()
|
||||
wait_transition.assert_awaited_once()
|
||||
assert mock_captcha.call_count == 1
|
||||
assert mock_input.call_count == 3 # Username, clear password, set password
|
||||
assert mock_click.call_count == 1 # Submit button
|
||||
assert mock_input.call_count == 2
|
||||
assert mock_click.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_login_data_and_send_logs_generic_start_message(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with (
|
||||
caplog.at_level("INFO"),
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_post_auth0_submit_transition", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input"),
|
||||
patch.object(test_bot, "web_click"),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
):
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
assert "Logging in..." in caplog.text
|
||||
assert test_bot.config.login.username not in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_login_data_and_send_fails_when_password_step_missing(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Missing Auth0 password step should fail fast."""
|
||||
with (
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock, side_effect = AssertionError("missing password")),
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
):
|
||||
with pytest.raises(AssertionError, match = "missing password"):
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
assert mock_input.call_count == 1
|
||||
assert mock_click.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_url_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""URL transition success should return without fallback checks."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as mock_wait,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_dom_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""DOM fallback should run when URL transition is inconclusive."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, return_value = True) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
mock_is_logged_in.assert_awaited_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_sleep_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Sleep fallback should run when bounded login check times out."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
assert mock_is_logged_in.await_count == 2
|
||||
mock_sleep.assert_awaited_once()
|
||||
assert mock_sleep.await_args is not None
|
||||
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
|
||||
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_sleep_fallback_when_login_not_confirmed(
|
||||
self, test_bot:KleinanzeigenBot
|
||||
) -> None:
|
||||
"""Sleep fallback should run when bounded login check returns False."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, return_value = False) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
assert mock_is_logged_in.await_count == 2
|
||||
mock_sleep.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_passes_click_timeout(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "_timeout", return_value = 1.25) as mock_timeout,
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
):
|
||||
await test_bot._click_gdpr_banner()
|
||||
|
||||
mock_timeout.assert_called_once_with("quick_dom")
|
||||
mock_find.assert_awaited_once_with(By.ID, "gdpr-banner-accept", timeout = 1.25)
|
||||
mock_click.assert_awaited_once_with(By.ID, "gdpr-banner-accept", timeout = 1.25)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_after_login_logic(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that post-login handling works correctly."""
|
||||
with (
|
||||
patch.object(test_bot, "web_find") as mock_find,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
patch.object(test_bot, "_check_sms_verification", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_sms,
|
||||
patch.object(test_bot, "_check_email_verification", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_email,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_gdpr,
|
||||
):
|
||||
# Test case 1: No special handling needed
|
||||
mock_find.side_effect = [TimeoutError(), TimeoutError(), TimeoutError()] # No phone verification, no email verification, no GDPR
|
||||
mock_click.return_value = AsyncMock()
|
||||
mock_ainput.return_value = ""
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 0
|
||||
assert mock_ainput.call_count == 0
|
||||
|
||||
# Test case 2: Phone verification needed
|
||||
mock_find.reset_mock()
|
||||
mock_click.reset_mock()
|
||||
mock_ainput.reset_mock()
|
||||
mock_find.side_effect = [AsyncMock(), TimeoutError(), TimeoutError()] # Phone verification found, no email verification, no GDPR
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 0 # No click needed, just wait for user
|
||||
assert mock_ainput.call_count == 1 # Wait for user to complete verification
|
||||
|
||||
# Test case 3: GDPR banner present
|
||||
mock_find.reset_mock()
|
||||
mock_click.reset_mock()
|
||||
mock_ainput.reset_mock()
|
||||
mock_find.side_effect = [TimeoutError(), TimeoutError(), AsyncMock()] # No phone verification, no email verification, GDPR found
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 2 # Click to accept GDPR and continue
|
||||
assert mock_ainput.call_count == 0
|
||||
mock_sms.assert_awaited_once()
|
||||
mock_email.assert_awaited_once()
|
||||
mock_gdpr.assert_awaited_once()
|
||||
|
||||
|
||||
class TestKleinanzeigenBotDiagnostics:
|
||||
@@ -864,9 +1030,10 @@ class TestKleinanzeigenBotDiagnostics:
|
||||
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
@@ -905,9 +1072,10 @@ class TestKleinanzeigenBotDiagnostics:
|
||||
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
@@ -1005,12 +1173,163 @@ class TestKleinanzeigenBotBasics:
|
||||
):
|
||||
await test_bot.publish_ads(ad_cfgs)
|
||||
|
||||
# With pagination, the URL now includes pageNum parameter
|
||||
web_request_mock.assert_awaited_once_with(f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1")
|
||||
# web_request is called once for initial published-ads snapshot
|
||||
expected_url = f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1"
|
||||
web_request_mock.assert_awaited_once_with(expected_url)
|
||||
publish_ad_mock.assert_awaited_once_with("ad.yaml", ad_cfgs[0][1], {}, [], AdUpdateStrategy.REPLACE)
|
||||
web_await_mock.assert_awaited_once()
|
||||
delete_ad_mock.assert_awaited_once_with(ad_cfgs[0][1], [], delete_old_ads_by_title = False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ads_uses_millisecond_retry_delay_on_retryable_failure(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Retry branch should sleep with explicit millisecond delay."""
|
||||
test_bot.page = mock_page
|
||||
test_bot.keep_old_ads = True
|
||||
|
||||
ad_cfg = Ad.model_validate(base_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
ad_file = "ad.yaml"
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = [TimeoutError("transient"), None]) as publish_mock,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
|
||||
assert publish_mock.await_count == 2
|
||||
sleep_mock.assert_awaited_once_with(2_000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ads_does_not_retry_when_submission_state_is_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Post-submit uncertainty must fail closed and skip retries."""
|
||||
test_bot.page = mock_page
|
||||
test_bot.keep_old_ads = True
|
||||
|
||||
ad_cfg = Ad.model_validate(base_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
ad_file = "ad.yaml"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_request",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
|
||||
),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"publish_ad",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = PublishSubmissionUncertainError("submission may have succeeded before failure"),
|
||||
) as publish_mock,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
|
||||
assert publish_mock.await_count == 1
|
||||
sleep_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_keeps_pre_submit_timeouts_retryable(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
) -> None:
|
||||
"""Timeouts before submit boundary should remain plain retryable failures."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")),
|
||||
pytest.raises(TimeoutError, match = "image upload timeout"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_marks_post_submit_timeout_as_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Timeouts after submit click should be converted to non-retryable uncertainty."""
|
||||
test_bot.page = mock_page
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
|
||||
if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm":
|
||||
raise TimeoutError("no payment form")
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
|
||||
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("confirmation timeout")),
|
||||
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_marks_post_submit_protocol_exception_as_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Protocol exceptions after submit click should be converted to uncertainty."""
|
||||
test_bot.page = mock_page
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
|
||||
if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm":
|
||||
raise TimeoutError("no payment form")
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
|
||||
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = ProtocolException(MagicMock(), "connection lost", 0)),
|
||||
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
def test_get_root_url(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Test root URL retrieval."""
|
||||
assert test_bot.root_url == "https://www.kleinanzeigen.de"
|
||||
@@ -1817,6 +2136,84 @@ class TestKleinanzeigenBotShippingOptions:
|
||||
mock_set_condition.assert_called_once_with("67890") # Converted to string
|
||||
|
||||
|
||||
class TestShippingSelectorTimeout:
|
||||
"""Regression tests for commercial shipping selector (versand_s) timeout handling.
|
||||
|
||||
Ensures that TimeoutError from web_check (element absent) is caught gracefully,
|
||||
while TimeoutError from web_select (element found but interaction fails) propagates.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_versand_s_falls_back_to_dialog(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When versand_s selector is absent, web_check raises TimeoutError and the bot falls through to dialog-based shipping."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = TimeoutError("element not found")) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
# web_select must NOT have been called with versand_s (commercial path was skipped)
|
||||
for call in mock_select.call_args_list:
|
||||
assert "versand_s" not in str(call), "web_select should not be called for versand_s when element is absent"
|
||||
|
||||
# Dialog-based fallback should have been triggered (click on "Versandmethoden auswählen")
|
||||
clicked_selectors = [str(c) for c in mock_click.call_args_list]
|
||||
assert any("Versandmethoden" in s for s in clicked_selectors), \
|
||||
"Expected dialog-based shipping fallback when versand_s is absent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visible_versand_s_uses_commercial_select(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When versand_s selector is present, web_check succeeds and web_select sets the value."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
# web_select must have been awaited with versand_s and "ja" (SHIPPING)
|
||||
mock_select.assert_awaited_once_with(By.XPATH, '//select[contains(@id, ".versand_s")]', "ja")
|
||||
|
||||
# Dialog-based fallback should NOT have been triggered
|
||||
clicked_selectors = [str(c) for c in mock_click.call_args_list]
|
||||
assert not any("Versandmethoden" in s for s in clicked_selectors), \
|
||||
"Dialog-based shipping should not be triggered when versand_s is present"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_select_timeout_propagates_after_successful_probe(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When web_check succeeds but web_select raises TimeoutError, the error must propagate (not be swallowed)."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock, side_effect = TimeoutError("select timed out")),
|
||||
pytest.raises(TimeoutError, match = "select timed out"),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
|
||||
class TestKleinanzeigenBotUrlConstruction:
|
||||
"""Tests for URL construction functionality."""
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class TestJSONPagination:
|
||||
async def test_fetch_published_ads_single_page_no_paging(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from single page with no paging info."""
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": '{"ads": [{"id": 1, "title": "Ad 1"}, {"id": 2, "title": "Ad 2"}]}'}
|
||||
mock_request.return_value = {"content": '{"ads": [{"id": 1, "state": "active", "title": "Ad 1"}, {"id": 2, "state": "active", "title": "Ad 2"}]}'}
|
||||
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_single_page_with_paging(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from single page with paging info showing 1/1."""
|
||||
response_data = {"ads": [{"id": 1, "title": "Ad 1"}], "paging": {"pageNum": 1, "last": 1}}
|
||||
response_data = {"ads": [{"id": 1, "state": "active", "title": "Ad 1"}], "paging": {"pageNum": 1, "last": 1}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -125,9 +125,9 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_multi_page(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from multiple pages (3 pages, 2 ads each)."""
|
||||
page1_data = {"ads": [{"id": 1}, {"id": 2}], "paging": {"pageNum": 1, "last": 3, "next": 2}}
|
||||
page2_data = {"ads": [{"id": 3}, {"id": 4}], "paging": {"pageNum": 2, "last": 3, "next": 3}}
|
||||
page3_data = {"ads": [{"id": 5}, {"id": 6}], "paging": {"pageNum": 3, "last": 3}}
|
||||
page1_data = {"ads": [{"id": 1, "state": "active"}, {"id": 2, "state": "active"}], "paging": {"pageNum": 1, "last": 3, "next": 2}}
|
||||
page2_data = {"ads": [{"id": 3, "state": "active"}, {"id": 4, "state": "active"}], "paging": {"pageNum": 2, "last": 3, "next": 3}}
|
||||
page3_data = {"ads": [{"id": 5, "state": "active"}, {"id": 6, "state": "active"}], "paging": {"pageNum": 3, "last": 3}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.side_effect = [
|
||||
@@ -176,7 +176,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_missing_paging_dict(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of missing paging dict."""
|
||||
response_data = {"ads": [{"id": 1}, {"id": 2}]}
|
||||
response_data = {"ads": [{"id": 1, "state": "active"}, {"id": 2, "state": "active"}]}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -190,7 +190,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_non_integer_paging_values(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of non-integer paging values."""
|
||||
response_data = {"ads": [{"id": 1}], "paging": {"pageNum": "invalid", "last": "also-invalid"}}
|
||||
response_data = {"ads": [{"id": 1, "state": "active"}], "paging": {"pageNum": "invalid", "last": "also-invalid"}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -219,6 +219,50 @@ class TestJSONPagination:
|
||||
if len(result) != 0:
|
||||
pytest.fail(f"expected empty list when 'ads' is not a list, got: {result}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_filters_non_dict_entries(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Malformed entries should be filtered and logged."""
|
||||
response_data = {"ads": [42, {"id": 1, "state": "active"}, "broken"], "paging": {"pageNum": 1, "last": 1}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != [{"id": 1, "state": "active"}]:
|
||||
pytest.fail(f"expected malformed entries to be filtered out, got: {result}")
|
||||
if "Filtered 2 malformed ad entries on page 1" not in caplog.text:
|
||||
pytest.fail(f"expected malformed-entry warning in logs, got: {caplog.text}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_filters_dict_entries_missing_required_keys(
|
||||
self,
|
||||
bot:KleinanzeigenBot,
|
||||
caplog:pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Dict entries without required id/state keys should be rejected."""
|
||||
response_data = {
|
||||
"ads": [
|
||||
{"id": 1},
|
||||
{"state": "active"},
|
||||
{"title": "missing both"},
|
||||
{"id": 2, "state": "paused"},
|
||||
],
|
||||
"paging": {"pageNum": 1, "last": 1},
|
||||
}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != [{"id": 2, "state": "paused"}]:
|
||||
pytest.fail(f"expected only entries with id and state to remain, got: {result}")
|
||||
if "Filtered 3 malformed ad entries on page 1" not in caplog.text:
|
||||
pytest.fail(f"expected malformed-entry warning in logs, got: {caplog.text}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_timeout(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of timeout during pagination."""
|
||||
@@ -229,3 +273,17 @@ class TestJSONPagination:
|
||||
|
||||
if result != []:
|
||||
pytest.fail(f"Expected empty list on timeout, got {result}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_handles_non_string_content_type(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Unexpected non-string content types should stop pagination with warning."""
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": None}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != []:
|
||||
pytest.fail(f"expected empty result on non-string content, got: {result}")
|
||||
if "Unexpected response content type on page 1: NoneType" not in caplog.text:
|
||||
pytest.fail(f"expected non-string content warning in logs, got: {caplog.text}")
|
||||
|
||||
Reference in New Issue
Block a user