From 12d929f8c66fb960d1ea1ec4e2e4243a3ce30286 Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 10:39:12 +0200 Subject: [PATCH 1/6] feat: Implement message sending functionality to Kleinanzeigen listings --- src/kleinanzeigen_bot/__init__.py | 53 +++++++++++++- src/kleinanzeigen_bot/message.py | 114 ++++++++++++++++++++++++++++++ tests/unit/test_bot.py | 19 +++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/kleinanzeigen_bot/message.py diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 15be46b2..00457091 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -22,6 +22,8 @@ from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale from .utils.misc import ainput, ensure, is_frozen from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin +from .message import Messenger + # W0406: possibly a bug, see https://github.com/PyCQA/pylint/issues/3933 @@ -60,6 +62,8 @@ def __init__(self) -> None: self.command = "help" self.ads_selector = "due" self.keep_old_ads = False + self.message_url = None + self.message_text = None def __del__(self) -> None: if self.file_log: @@ -182,6 +186,38 @@ async def run(self, args:list[str]) -> None: await self.create_browser_session() await self.login() await self.download_ads() + case "message": + self.configure_file_logging() + self.load_config() + # Optional, wie bei anderen Commands: + checker = UpdateChecker(self.config) + checker.check_for_updates() + + if not self.message_url or not self.message_text: + LOG.error('Usage: kleinanzeigen-bot message --url "" --text ""') + sys.exit(2) + + # URL/Text müssen durch parse_args gesetzt sein + # if not getattr(self, "message_url", None) or not getattr(self, "message_text", None): + # LOG.error('Usage: kleinanzeigen-bot message --url "" --text ""') + # sys.exit(2) + + # genau wie publish/update/delete/download: + await self.create_browser_session() + await self.login() + + + messenger = Messenger(self.browser, self.config) + ok = await messenger.send_message_to_listing(self.message_url, self.message_text) + + if ok: + LOG.info("############################################") + LOG.info("DONE: Message sent.") + LOG.info("############################################") + else: + LOG.info("############################################") + LOG.info("DONE: Message could not be confirmed.") + LOG.info("############################################") case _: LOG.error("Unknown command: %s", self.command) @@ -213,6 +249,7 @@ def show_help(self) -> None: "geändert" gelten und neu veröffentlicht werden. create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen + message - Sendet eine Nachricht an eine einzelne Anzeige -- help - Zeigt diese Hilfe an (Standardbefehl) version - Zeigt die Version der Anwendung an @@ -298,13 +335,19 @@ def parse_args(self, args:list[str]) -> None: "keep-old", "logfile=", "lang=", - "verbose" + "verbose", + "url=", + "text=", ]) except getopt.error as ex: LOG.error(ex.msg) LOG.error("Use --help to display available options.") sys.exit(2) + # reset command-specific state before applying new options + self.message_url = None + self.message_text = None + for option, value in options: match option: case "-h" | "--help": @@ -328,6 +371,10 @@ def parse_args(self, args:list[str]) -> None: case "-v" | "--verbose": LOG.setLevel(loggers.DEBUG) loggers.get_logger("nodriver").setLevel(loggers.INFO) + case "--url": + self.message_url = value.strip() + case "--text": + self.message_text = value match len(arguments): case 0: @@ -338,6 +385,10 @@ def parse_args(self, args:list[str]) -> None: LOG.error("More than one command given: %s", arguments) sys.exit(2) + if self.command == "message" and (not self.message_url or not self.message_text): + LOG.error('Usage: kleinanzeigen-bot message --url "" --text ""') + sys.exit(2) + def configure_file_logging(self) -> None: if not self.log_file_path: return diff --git a/src/kleinanzeigen_bot/message.py b/src/kleinanzeigen_bot/message.py new file mode 100644 index 00000000..bb5003eb --- /dev/null +++ b/src/kleinanzeigen_bot/message.py @@ -0,0 +1,114 @@ +from __future__ import annotations +from typing import Final, Iterable + +from .model.config_model import Config +from .utils import loggers, i18n +from .utils.exceptions import KleinanzeigenBotError +from .utils.web_scraping_mixin import By, WebScrapingMixin, Element, Browser + +LOG: Final[loggers.Logger] = loggers.get_logger(__name__) + + +class Messenger(WebScrapingMixin): + """Send a message to a single Kleinanzeigen listing using only WebScrapingMixin APIs.""" + + def __init__(self, browser: Browser, config: Config) -> None: + super().__init__() + self.config = config + self.browser = browser + + # --------------------------- + # public API + # --------------------------- + async def send_message_to_listing(self, listing_url: str, message_text: str) -> bool: + # LOG.info(i18n.gettext("Opening ad page: %s"), listing_url) + await self.web_open(listing_url, timeout=15_000) + + # Cookiebanner (best effort, niemals hart fehlschlagen) + await self._dismiss_cookies_if_present() + + # Kleiner Scroll, damit „Kontakt“-Button gerendert ist + try: + await self.web_execute("window.scrollBy(0, 400)") + except Exception: # noqa: BLE001 + pass + + # 1) „Nachricht“/„Kontakt“-Button öffnen + open_btn_candidates = [ + (By.ID, "viewad-contact-button"), + (By.CSS_SELECTOR, "#viewad-contact-button"), + (By.CSS_SELECTOR, "[data-testid='contact-seller']"), + (By.TEXT, "Nachricht"), # best-match Textsuche des Mixins + (By.TEXT, "Kontakt"), + (By.CSS_SELECTOR, "a[href*='nachricht'], a[href*='message'], button[data-testid*='message']"), + ] + btn = await self._try_click(open_btn_candidates, desc="message open button", timeout=6) + + # 2) Textarea finden & Text eingeben + textarea_candidates = [ + (By.CSS_SELECTOR, "textarea[name='message']"), + (By.CSS_SELECTOR, "#message"), + (By.CSS_SELECTOR, "[data-testid='message-textarea']"), + (By.TAG_NAME, "textarea"), + ] + textarea = await self._try_find(textarea_candidates, desc="message textarea", timeout=8) + await textarea.clear_input() + await textarea.send_keys(message_text) + await self.web_sleep(300, 600) + + # 3) „Senden“-Button + send_btn_candidates = [ + (By.TEXT, "Nachricht senden"), + (By.TEXT, "Senden"), + (By.CSS_SELECTOR, "[data-testid='send-message']"), + (By.CSS_SELECTOR, "button[type='submit']"), + ] + await self._try_click(send_btn_candidates, desc="send message button", timeout=6) + + # 4) kurze Heuristik/Abschluss + await self.web_sleep(700, 1200) + LOG.info("Message flow finished (no error detected).") + return True + + # --------------------------- + # local helpers (tiny) + # --------------------------- + async def _try_click(self, candidates: Iterable[tuple[By, str]], *, desc: str, timeout: int = 5) -> Element: + last_err: Exception | None = None + for by, sel in candidates: + try: + elem = await self.web_find(by, sel, timeout=timeout) + await elem.click() + await self.web_sleep(150, 300) + return elem + except Exception as ex: # noqa: BLE001 + last_err = ex + raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err + + async def _try_find(self, candidates: Iterable[tuple[By, str]], *, desc: str, timeout: int = 5) -> Element: + last_err: Exception | None = None + for by, sel in candidates: + try: + return await self.web_find(by, sel, timeout=timeout) + except Exception as ex: # noqa: BLE001 + last_err = ex + raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err + + async def _dismiss_cookies_if_present(self) -> None: + try: + for cand in ( + (By.TEXT, "Akzeptieren"), + (By.TEXT, "Einverstanden"), + (By.TEXT, "OK"), + (By.CSS_SELECTOR, "button#didomi-notice-agree-button"), + (By.CSS_SELECTOR, "button[aria-label*='Akzeptieren']"), + ): + try: + btn = await self.web_find(*cand, timeout=1.5) + await btn.click() + await self.web_sleep(150, 300) + break + except Exception: + continue + except Exception: + pass diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index 35cbf45e..7299a052 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -31,6 +31,25 @@ def test_parse_args_create_config(self, bot:KleinanzeigenBot) -> None: bot.parse_args(["app", "create-config"]) assert bot.command == "create-config" + def test_parse_args_message(self, bot:KleinanzeigenBot) -> None: + """Test parsing of message command with required options""" + bot.parse_args([ + "app", + "message", + "--url", + "https://example.com/listing", + "--text", + "Hello there", + ]) + assert bot.command == "message" + assert bot.message_url == "https://example.com/listing" + assert bot.message_text == "Hello there" + + def test_parse_args_message_requires_url_and_text(self, bot:KleinanzeigenBot) -> None: + """Ensure message command exits when required options are missing""" + with pytest.raises(SystemExit): + bot.parse_args(["app", "message"]) + def test_create_default_config_logs_error_if_exists(self, tmp_path:pathlib.Path, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: """Test that create_default_config logs an error if the config file already exists.""" config_path = tmp_path / "config.yaml" From 15ab961d33299825ff52047b0aa6e4a7a950aa62 Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 11:03:17 +0200 Subject: [PATCH 2/6] feat: Add message command to send direct messages to Kleinanzeigen listings --- README.md | 14 +++++++ src/kleinanzeigen_bot/__init__.py | 12 ++---- src/kleinanzeigen_bot/message.py | 68 ++++++++++++------------------- 3 files changed, 44 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 3899c876..519570f3 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ Commands: use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished create-config - creates a new default configuration file if one does not exist diagnose - diagnoses browser connection issues and shows troubleshooting information + message - sends a direct message to a single listing (requires --url and --text) -- help - displays this help (default command) version - displays the application version @@ -224,12 +225,25 @@ Options: --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) --lang=en|de - display language (STANDARD: system language if supported, otherwise English) -v, --verbose - enables verbose output - only useful when troubleshooting issues + --url= (message) - Kleinanzeigen listing URL to message + --text= (message) - message body to send ``` > **Note:** The output of `kleinanzeigen-bot help` is always the most up-to-date reference for available commands and options. Limitation of `download`: It's only possible to extract the cheapest given shipping option. +### Message command + +Use the `message` command to send a one-off inquiry to a listing without launching the full publishing flow: + +```bash +pdm run app message --url "https://www.kleinanzeigen.de/s-anzeige/" \ + --text "Hi! Ist der Artikel noch verfügbar?" +``` + +The command reuses your configured browser profile, so make sure you're logged in before attempting to send messages. + ## Configuration All configuration files can be in YAML or JSON format. diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 00457091..41129406 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -13,6 +13,7 @@ from . import extract, resources from ._version import __version__ +from .message import Messenger from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial from .model.config_model import Config from .update_checker import UpdateChecker @@ -22,8 +23,6 @@ from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale from .utils.misc import ainput, ensure, is_frozen from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin -from .message import Messenger - # W0406: possibly a bug, see https://github.com/PyCQA/pylint/issues/3933 @@ -62,8 +61,8 @@ def __init__(self) -> None: self.command = "help" self.ads_selector = "due" self.keep_old_ads = False - self.message_url = None - self.message_text = None + self.message_url: str | None = None + self.message_text: str | None = None def __del__(self) -> None: if self.file_log: @@ -206,7 +205,6 @@ async def run(self, args:list[str]) -> None: await self.create_browser_session() await self.login() - messenger = Messenger(self.browser, self.config) ok = await messenger.send_message_to_listing(self.message_url, self.message_text) @@ -344,10 +342,6 @@ def parse_args(self, args:list[str]) -> None: LOG.error("Use --help to display available options.") sys.exit(2) - # reset command-specific state before applying new options - self.message_url = None - self.message_text = None - for option, value in options: match option: case "-h" | "--help": diff --git a/src/kleinanzeigen_bot/message.py b/src/kleinanzeigen_bot/message.py index bb5003eb..40129428 100644 --- a/src/kleinanzeigen_bot/message.py +++ b/src/kleinanzeigen_bot/message.py @@ -1,18 +1,26 @@ +# src/kleinanzeigen_bot/message.py +# SPDX-FileCopyrightText: © Contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ + from __future__ import annotations -from typing import Final, Iterable -from .model.config_model import Config -from .utils import loggers, i18n +from typing import TYPE_CHECKING, Final, Iterable + +from .utils import loggers from .utils.exceptions import KleinanzeigenBotError -from .utils.web_scraping_mixin import By, WebScrapingMixin, Element, Browser +from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin + +if TYPE_CHECKING: + from .model.config_model import Config -LOG: Final[loggers.Logger] = loggers.get_logger(__name__) +LOG:Final[loggers.Logger] = loggers.get_logger(__name__) class Messenger(WebScrapingMixin): """Send a message to a single Kleinanzeigen listing using only WebScrapingMixin APIs.""" - def __init__(self, browser: Browser, config: Config) -> None: + def __init__(self, browser:Browser, config:Config) -> None: super().__init__() self.config = config self.browser = browser @@ -20,18 +28,15 @@ def __init__(self, browser: Browser, config: Config) -> None: # --------------------------- # public API # --------------------------- - async def send_message_to_listing(self, listing_url: str, message_text: str) -> bool: + async def send_message_to_listing(self, listing_url:str, message_text:str) -> bool: # LOG.info(i18n.gettext("Opening ad page: %s"), listing_url) - await self.web_open(listing_url, timeout=15_000) - - # Cookiebanner (best effort, niemals hart fehlschlagen) - await self._dismiss_cookies_if_present() + await self.web_open(listing_url, timeout = 15_000) # Kleiner Scroll, damit „Kontakt“-Button gerendert ist try: await self.web_execute("window.scrollBy(0, 400)") - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + LOG.debug("Scroll preloading of message button failed", exc_info = exc) # 1) „Nachricht“/„Kontakt“-Button öffnen open_btn_candidates = [ @@ -42,7 +47,7 @@ async def send_message_to_listing(self, listing_url: str, message_text: str) -> (By.TEXT, "Kontakt"), (By.CSS_SELECTOR, "a[href*='nachricht'], a[href*='message'], button[data-testid*='message']"), ] - btn = await self._try_click(open_btn_candidates, desc="message open button", timeout=6) + await self._try_click(open_btn_candidates, desc = "message open button", timeout = 6) # 2) Textarea finden & Text eingeben textarea_candidates = [ @@ -51,7 +56,7 @@ async def send_message_to_listing(self, listing_url: str, message_text: str) -> (By.CSS_SELECTOR, "[data-testid='message-textarea']"), (By.TAG_NAME, "textarea"), ] - textarea = await self._try_find(textarea_candidates, desc="message textarea", timeout=8) + textarea = await self._try_find(textarea_candidates, desc = "message textarea", timeout = 8) await textarea.clear_input() await textarea.send_keys(message_text) await self.web_sleep(300, 600) @@ -63,7 +68,7 @@ async def send_message_to_listing(self, listing_url: str, message_text: str) -> (By.CSS_SELECTOR, "[data-testid='send-message']"), (By.CSS_SELECTOR, "button[type='submit']"), ] - await self._try_click(send_btn_candidates, desc="send message button", timeout=6) + await self._try_click(send_btn_candidates, desc = "send message button", timeout = 6) # 4) kurze Heuristik/Abschluss await self.web_sleep(700, 1200) @@ -73,11 +78,11 @@ async def send_message_to_listing(self, listing_url: str, message_text: str) -> # --------------------------- # local helpers (tiny) # --------------------------- - async def _try_click(self, candidates: Iterable[tuple[By, str]], *, desc: str, timeout: int = 5) -> Element: - last_err: Exception | None = None + async def _try_click(self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5) -> Element: + last_err:Exception | None = None for by, sel in candidates: try: - elem = await self.web_find(by, sel, timeout=timeout) + elem = await self.web_find(by, sel, timeout = timeout) await elem.click() await self.web_sleep(150, 300) return elem @@ -85,30 +90,11 @@ async def _try_click(self, candidates: Iterable[tuple[By, str]], *, desc: str, t last_err = ex raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err - async def _try_find(self, candidates: Iterable[tuple[By, str]], *, desc: str, timeout: int = 5) -> Element: - last_err: Exception | None = None + async def _try_find(self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5) -> Element: + last_err:Exception | None = None for by, sel in candidates: try: - return await self.web_find(by, sel, timeout=timeout) + return await self.web_find(by, sel, timeout = timeout) except Exception as ex: # noqa: BLE001 last_err = ex raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err - - async def _dismiss_cookies_if_present(self) -> None: - try: - for cand in ( - (By.TEXT, "Akzeptieren"), - (By.TEXT, "Einverstanden"), - (By.TEXT, "OK"), - (By.CSS_SELECTOR, "button#didomi-notice-agree-button"), - (By.CSS_SELECTOR, "button[aria-label*='Akzeptieren']"), - ): - try: - btn = await self.web_find(*cand, timeout=1.5) - await btn.click() - await self.web_sleep(150, 300) - break - except Exception: - continue - except Exception: - pass From 2b400ed32b36df70e31ded3fae8360c969febe3a Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 11:06:36 +0200 Subject: [PATCH 3/6] fix: Update message_url and message_text type annotations; change input to ainput for async compatibility --- src/kleinanzeigen_bot/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 41129406..dc5cdca5 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -61,8 +61,8 @@ def __init__(self) -> None: self.command = "help" self.ads_selector = "due" self.keep_old_ads = False - self.message_url: str | None = None - self.message_text: str | None = None + self.message_url:str | None = None + self.message_text:str | None = None def __del__(self) -> None: if self.file_log: @@ -972,7 +972,7 @@ async def publish_ad(self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], p LOG.warning("# Payment form detected! Please proceed with payment.") LOG.warning("############################################") await self.web_scroll_page_down() - input(_("Press a key to continue...")) + await ainput(_("Press a key to continue...")) except TimeoutError: pass From bfbd95d8849ec8e4b8259e25c9f3f0f37974ecb7 Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 16:05:57 +0200 Subject: [PATCH 4/6] feat: Add German translations for message command usage and status messages --- src/kleinanzeigen_bot/resources/translations.de.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index ca76bde9..b0dbca30 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -143,6 +143,7 @@ kleinanzeigen_bot/__init__.py: parse_args: "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." "More than one command given: %s": "Mehr als ein Befehl angegeben: %s" + "Usage: kleinanzeigen-bot message --url \"\" --text \"\"": "Verwendung: kleinanzeigen-bot message --url \"\" --text \"\"" run: "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." @@ -153,6 +154,9 @@ kleinanzeigen_bot/__init__.py: "DONE: No changed ads found.": "FERTIG: Keine geänderten Anzeigen gefunden." "You provided no ads selector. Defaulting to \"new\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"new\" verwendet." "You provided no ads selector. Defaulting to \"changed\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"changed\" verwendet." + "DONE: Message sent.": "FERTIG: Nachricht gesendet." + "DONE: Message could not be confirmed.": "FERTIG: Nachricht konnte nicht bestätigt werden." + "Usage: kleinanzeigen-bot message --url \"\" --text \"\"": "Verwendung: kleinanzeigen-bot message --url \"\" --text \"\"" "Unknown command: %s": "Unbekannter Befehl: %s" fill_login_data_and_send: @@ -169,6 +173,12 @@ kleinanzeigen_bot/__init__.py: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." "ad": "Anzeige" +################################################# +kleinanzeigen_bot/message.py: +################################################# + send_message_to_listing: + "Message flow finished (no error detected).": "Nachrichtenablauf abgeschlossen (kein Fehler erkannt)." + ################################################# kleinanzeigen_bot/extract.py: ################################################# From 5835fe018e0dafd492e4a28d4dace1eb19b084d0 Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 17:44:38 +0200 Subject: [PATCH 5/6] feat: Enhance Messenger and WebScrapingMixin to support user profile handling and conversation fetching --- src/kleinanzeigen_bot/__init__.py | 14 ++++- src/kleinanzeigen_bot/message.py | 60 +++++++++++++++---- .../utils/web_scraping_mixin.py | 3 + 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index dc5cdca5..1a64d8e3 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -5,7 +5,7 @@ import getopt # pylint: disable=deprecated-module import urllib.parse as urllib_parse from gettext import gettext as _ -from typing import Any, Final +from typing import Any, Dict, Final import certifi, colorama, nodriver # isort: skip from ruamel.yaml import YAML @@ -205,7 +205,7 @@ async def run(self, args:list[str]) -> None: await self.create_browser_session() await self.login() - messenger = Messenger(self.browser, self.config) + messenger = Messenger(self.browser, self.page, self.config, self.mein_profil) ok = await messenger.send_message_to_listing(self.message_url, self.message_text) if ok: @@ -690,17 +690,27 @@ async def is_logged_in(self) -> bool: # Try to find the standard element first user_info = await self.web_text(By.CLASS_NAME, "mr-medium") if self.config.login.username.lower() in user_info.lower(): + self.mein_profil = await self.get_user_info() return True except TimeoutError: try: # If standard element not found, try the alternative user_info = await self.web_text(By.ID, "user-email") if self.config.login.username.lower() in user_info.lower(): + self.mein_profil = await self.get_user_info() return True except TimeoutError: return False return False + async def get_user_info(self) -> dict[str, Any]: + url = f"{self.root_url}/m-mein-profil.json" + # Fetch user profile JSON data and parse the string response as JSON + info:Dict[str, Any] = json.loads((await self.web_request(url))["content"]) + authorization_headers = (await self.web_request(f"{self.root_url}/m-access-token.json"))["headers"] + info["authorization_headers"] = authorization_headers + return info + async def delete_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: count = 0 diff --git a/src/kleinanzeigen_bot/message.py b/src/kleinanzeigen_bot/message.py index 40129428..fce8dcc7 100644 --- a/src/kleinanzeigen_bot/message.py +++ b/src/kleinanzeigen_bot/message.py @@ -5,25 +5,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final, Iterable +import json +from typing import TYPE_CHECKING, Any, Final, Iterable from .utils import loggers from .utils.exceptions import KleinanzeigenBotError from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin if TYPE_CHECKING: + from nodriver.core.tab import Tab as Page + from .model.config_model import Config + LOG:Final[loggers.Logger] = loggers.get_logger(__name__) class Messenger(WebScrapingMixin): """Send a message to a single Kleinanzeigen listing using only WebScrapingMixin APIs.""" - def __init__(self, browser:Browser, config:Config) -> None: + def __init__( + self, browser:Browser, page:Page, config:Config, mein_profil:dict[str, Any] + ) -> None: super().__init__() self.config = config self.browser = browser + self.page = page + self.mein_profil = mein_profil # --------------------------- # public API @@ -45,9 +53,14 @@ async def send_message_to_listing(self, listing_url:str, message_text:str) -> bo (By.CSS_SELECTOR, "[data-testid='contact-seller']"), (By.TEXT, "Nachricht"), # best-match Textsuche des Mixins (By.TEXT, "Kontakt"), - (By.CSS_SELECTOR, "a[href*='nachricht'], a[href*='message'], button[data-testid*='message']"), + ( + By.CSS_SELECTOR, + "a[href*='nachricht'], a[href*='message'], button[data-testid*='message']", + ), ] - await self._try_click(open_btn_candidates, desc = "message open button", timeout = 6) + await self._try_click( + open_btn_candidates, desc = "message open button", timeout = 6 + ) # 2) Textarea finden & Text eingeben textarea_candidates = [ @@ -56,7 +69,9 @@ async def send_message_to_listing(self, listing_url:str, message_text:str) -> bo (By.CSS_SELECTOR, "[data-testid='message-textarea']"), (By.TAG_NAME, "textarea"), ] - textarea = await self._try_find(textarea_candidates, desc = "message textarea", timeout = 8) + textarea = await self._try_find( + textarea_candidates, desc = "message textarea", timeout = 8 + ) await textarea.clear_input() await textarea.send_keys(message_text) await self.web_sleep(300, 600) @@ -68,7 +83,9 @@ async def send_message_to_listing(self, listing_url:str, message_text:str) -> bo (By.CSS_SELECTOR, "[data-testid='send-message']"), (By.CSS_SELECTOR, "button[type='submit']"), ] - await self._try_click(send_btn_candidates, desc = "send message button", timeout = 6) + await self._try_click( + send_btn_candidates, desc = "send message button", timeout = 6 + ) # 4) kurze Heuristik/Abschluss await self.web_sleep(700, 1200) @@ -78,7 +95,9 @@ async def send_message_to_listing(self, listing_url:str, message_text:str) -> bo # --------------------------- # local helpers (tiny) # --------------------------- - async def _try_click(self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5) -> Element: + async def _try_click( + self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5 + ) -> Element: last_err:Exception | None = None for by, sel in candidates: try: @@ -88,13 +107,34 @@ async def _try_click(self, candidates:Iterable[tuple[By, str]], *, desc:str, tim return elem except Exception as ex: # noqa: BLE001 last_err = ex - raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err + raise KleinanzeigenBotError( + f"Could not locate element for: {desc}" + ) from last_err - async def _try_find(self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5) -> Element: + async def _try_find( + self, candidates:Iterable[tuple[By, str]], *, desc:str, timeout:int = 5 + ) -> Element: last_err:Exception | None = None for by, sel in candidates: try: return await self.web_find(by, sel, timeout = timeout) except Exception as ex: # noqa: BLE001 last_err = ex - raise KleinanzeigenBotError(f"Could not locate element for: {desc}") from last_err + raise KleinanzeigenBotError( + f"Could not locate element for: t{desc}" + ) from last_err + + async def fetch_conversations(self, limit:int = 10) -> list[dict[str, str]]: + page = 0 + conversations:list[dict[str, str]] = [] + while len(conversations) < limit: + convo_url = f"{self.api_root_url}/messagebox/api/users/{self.mein_profil['userId']}/conversations?page={page}&size=10" + + data = json.loads((await self.web_request( + convo_url, headers = self.mein_profil.get("authorization_headers", {}) + ))["content"]) + conversations.extend(data.get("conversations", [])) + if not data.get("_links", {}).get("next"): + break + page += 1 + return conversations diff --git a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py index 580ffc23..9f35b8f4 100644 --- a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py @@ -88,6 +88,9 @@ def __init__(self) -> None: self.browser_config:Final[BrowserConfig] = BrowserConfig() self.browser:Browser = None # pyright: ignore[reportAttributeAccessIssue] self.page:Page = None # pyright: ignore[reportAttributeAccessIssue] + self.mein_profil:dict[str, Any] = {} + self.root_url = "https://www.kleinanzeigen.de" + self.api_root_url = "https://gateway.kleinanzeigen.de" async def create_browser_session(self) -> None: LOG.info("Creating Browser session...") From dce10892a5a0a787dbd60848deb532992ae4c231 Mon Sep 17 00:00:00 2001 From: wahed Date: Sun, 28 Sep 2025 20:53:36 +0200 Subject: [PATCH 6/6] feat: Add fetch-conversations and fetch-conversation commands with argument parsing and validation --- src/kleinanzeigen_bot/__init__.py | 85 ++++++++++++++++++- src/kleinanzeigen_bot/message.py | 20 ++++- .../resources/translations.de.yaml | 7 ++ tests/unit/test_bot.py | 33 +++++++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 1a64d8e3..970d38c8 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -63,6 +63,8 @@ def __init__(self) -> None: self.keep_old_ads = False self.message_url:str | None = None self.message_text:str | None = None + self.conversation_limit:int = 10 + self.conversation_id:str | None = None def __del__(self) -> None: if self.file_log: @@ -217,6 +219,47 @@ async def run(self, args:list[str]) -> None: LOG.info("DONE: Message could not be confirmed.") LOG.info("############################################") + case "fetch-conversations": + self.configure_file_logging() + self.load_config() + checker = UpdateChecker(self.config) + checker.check_for_updates() + + await self.create_browser_session() + await self.login() + if not self.mein_profil: + self.mein_profil = await self.get_user_info() + + messenger = Messenger(self.browser, self.page, self.config, self.mein_profil) + conversations = await messenger.fetch_conversations(self.conversation_limit) + + if conversations: + LOG.info("############################################") + LOG.info("DONE: Retrieved %s", pluralize("conversation", conversations)) + LOG.info("############################################") + print(json.dumps(conversations, ensure_ascii = False, indent = 2)) + else: + LOG.info("############################################") + LOG.info("DONE: No conversations found.") + LOG.info("############################################") + + case "fetch-conversation": + self.configure_file_logging() + self.load_config() + checker = UpdateChecker(self.config) + checker.check_for_updates() + + await self.create_browser_session() + await self.login() + + messenger = Messenger(self.browser, self.page, self.config, self.mein_profil) + conversation = await messenger.fetch_conversation(self.conversation_id or "") + + LOG.info("############################################") + LOG.info("DONE: Conversation retrieved.") + LOG.info("############################################") + print(json.dumps(conversation, ensure_ascii = False, indent = 2)) + case _: LOG.error("Unknown command: %s", self.command) sys.exit(2) @@ -248,6 +291,8 @@ def show_help(self) -> None: create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen message - Sendet eine Nachricht an eine einzelne Anzeige + fetch-conversations - Ruft Nachrichtenunterhaltungen aus der Inbox ab + fetch-conversation - Ruft eine einzelne Unterhaltung per ID ab -- help - Zeigt diese Hilfe an (Standardbefehl) version - Zeigt die Version der Anwendung an @@ -277,6 +322,8 @@ def show_help(self) -> None: --logfile= - Pfad zur Protokolldatei (STANDARD: ./kleinanzeigen-bot.log) --lang=en|de - Anzeigesprache (STANDARD: Systemsprache, wenn unterstützt, sonst Englisch) -v, --verbose - Aktiviert detaillierte Ausgabe – nur nützlich zur Fehlerbehebung + --limit= - (fetch-conversations) Maximale Anzahl abzurufender Unterhaltungen (STANDARD: 10) + --conversation-id= - (fetch-conversation) ID der abzurufenden Unterhaltung """.rstrip())) else: print(textwrap.dedent(f"""\ @@ -293,6 +340,9 @@ def show_help(self) -> None: use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished create-config - creates a new default configuration file if one does not exist diagnose - diagnoses browser connection issues and shows troubleshooting information + message - sends a message to a single listing + fetch-conversations - retrieves message conversations from your inbox + fetch-conversation - retrieves a single conversation by ID -- help - displays this help (default command) version - displays the application version @@ -321,6 +371,8 @@ def show_help(self) -> None: --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) --lang=en|de - display language (STANDARD: system language if supported, otherwise English) -v, --verbose - enables verbose output - only useful when troubleshooting issues + --limit= - (fetch-conversations) maximum number of conversations to fetch (DEFAULT: 10) + --conversation-id= - (fetch-conversation) ID of the conversation to retrieve """.rstrip())) def parse_args(self, args:list[str]) -> None: @@ -336,6 +388,8 @@ def parse_args(self, args:list[str]) -> None: "verbose", "url=", "text=", + "limit=", + "conversation-id=", ]) except getopt.error as ex: LOG.error(ex.msg) @@ -369,6 +423,14 @@ def parse_args(self, args:list[str]) -> None: self.message_url = value.strip() case "--text": self.message_text = value + case "--limit": + try: + self.conversation_limit = int(value) + except ValueError: + LOG.error("--limit expects an integer but got: %s", value) + sys.exit(2) + case "--conversation-id": + self.conversation_id = value.strip() match len(arguments): case 0: @@ -383,6 +445,14 @@ def parse_args(self, args:list[str]) -> None: LOG.error('Usage: kleinanzeigen-bot message --url "" --text ""') sys.exit(2) + if self.command == "fetch-conversations" and self.conversation_limit <= 0: + LOG.error("--limit must be a positive integer") + sys.exit(2) + + if self.command == "fetch-conversation" and not self.conversation_id: + LOG.error('Usage: kleinanzeigen-bot fetch-conversation --conversation-id ""') + sys.exit(2) + def configure_file_logging(self) -> None: if not self.log_file_path: return @@ -690,24 +760,33 @@ async def is_logged_in(self) -> bool: # Try to find the standard element first user_info = await self.web_text(By.CLASS_NAME, "mr-medium") if self.config.login.username.lower() in user_info.lower(): - self.mein_profil = await self.get_user_info() + await self._refresh_mein_profil_if_possible() return True except TimeoutError: try: # If standard element not found, try the alternative user_info = await self.web_text(By.ID, "user-email") if self.config.login.username.lower() in user_info.lower(): - self.mein_profil = await self.get_user_info() + await self._refresh_mein_profil_if_possible() return True except TimeoutError: return False return False + async def _refresh_mein_profil_if_possible(self) -> None: + if self.page is None: + return + try: + self.mein_profil = await self.get_user_info() + except Exception as exc: # noqa: BLE001 + LOG.debug("Unable to refresh user profile after login check", exc_info = exc) + async def get_user_info(self) -> dict[str, Any]: url = f"{self.root_url}/m-mein-profil.json" # Fetch user profile JSON data and parse the string response as JSON info:Dict[str, Any] = json.loads((await self.web_request(url))["content"]) - authorization_headers = (await self.web_request(f"{self.root_url}/m-access-token.json"))["headers"] + auth_response = await self.web_request(f"{self.root_url}/m-access-token.json") + authorization_headers = auth_response.get("headers", {}) info["authorization_headers"] = authorization_headers return info diff --git a/src/kleinanzeigen_bot/message.py b/src/kleinanzeigen_bot/message.py index fce8dcc7..fd1bb2cb 100644 --- a/src/kleinanzeigen_bot/message.py +++ b/src/kleinanzeigen_bot/message.py @@ -121,14 +121,19 @@ async def _try_find( except Exception as ex: # noqa: BLE001 last_err = ex raise KleinanzeigenBotError( - f"Could not locate element for: t{desc}" + f"Could not locate element for: {desc}" ) from last_err async def fetch_conversations(self, limit:int = 10) -> list[dict[str, str]]: page = 0 conversations:list[dict[str, str]] = [] while len(conversations) < limit: - convo_url = f"{self.api_root_url}/messagebox/api/users/{self.mein_profil['userId']}/conversations?page={page}&size=10" + remaining = max(limit - len(conversations), 1) + page_size = min(remaining, 10) + convo_url = ( + f"{self.api_root_url}/messagebox/api/users/{self.mein_profil['userId']}" + f"/conversations?page={page}&size={page_size}" + ) data = json.loads((await self.web_request( convo_url, headers = self.mein_profil.get("authorization_headers", {}) @@ -138,3 +143,14 @@ async def fetch_conversations(self, limit:int = 10) -> list[dict[str, str]]: break page += 1 return conversations + + async def fetch_conversation(self, conversation_id:str) -> dict[str, Any]: + convo_url = ( + f"{self.api_root_url}/messagebox/api/users/{self.mein_profil['userId']}" + f"/conversations/{conversation_id}?contentWarnings=true" + ) + + data = json.loads((await self.web_request( + convo_url, headers = self.mein_profil.get("authorization_headers", {}) + ))["content"]) + return data diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index b0dbca30..11674d50 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -144,6 +144,9 @@ kleinanzeigen_bot/__init__.py: "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." "More than one command given: %s": "Mehr als ein Befehl angegeben: %s" "Usage: kleinanzeigen-bot message --url \"\" --text \"\"": "Verwendung: kleinanzeigen-bot message --url \"\" --text \"\"" + "--limit expects an integer but got: %s": "--limit erwartet eine Ganzzahl, erhalten: %s" + "--limit must be a positive integer": "--limit muss eine positive Ganzzahl sein" + "Usage: kleinanzeigen-bot fetch-conversation --conversation-id \"\"": "Verwendung: kleinanzeigen-bot fetch-conversation --conversation-id \"\"" run: "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." @@ -157,7 +160,11 @@ kleinanzeigen_bot/__init__.py: "DONE: Message sent.": "FERTIG: Nachricht gesendet." "DONE: Message could not be confirmed.": "FERTIG: Nachricht konnte nicht bestätigt werden." "Usage: kleinanzeigen-bot message --url \"\" --text \"\"": "Verwendung: kleinanzeigen-bot message --url \"\" --text \"\"" + "DONE: Retrieved %s": "FERTIG: %s abgerufen" + "DONE: No conversations found.": "FERTIG: Keine Unterhaltungen gefunden." + "DONE: Conversation retrieved.": "FERTIG: Unterhaltung abgerufen." "Unknown command: %s": "Unbekannter Befehl: %s" + "conversation": "Unterhaltung" fill_login_data_and_send: "Logging in as [%s]...": "Anmeldung als [%s]..." diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index 7299a052..d8c597b8 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -50,6 +50,39 @@ def test_parse_args_message_requires_url_and_text(self, bot:KleinanzeigenBot) -> with pytest.raises(SystemExit): bot.parse_args(["app", "message"]) + def test_parse_args_fetch_conversations_with_limit(self, bot:KleinanzeigenBot) -> None: + """Ensure fetch-conversations applies the provided limit""" + bot.parse_args(["app", "fetch-conversations", "--limit=7"]) + assert bot.command == "fetch-conversations" + assert bot.conversation_limit == 7 + + def test_parse_args_fetch_conversations_invalid_limit(self, bot:KleinanzeigenBot) -> None: + """Invalid limits should exit""" + with pytest.raises(SystemExit): + bot.parse_args(["app", "fetch-conversations", "--limit=0"]) + + def test_parse_args_fetch_conversation(self, bot:KleinanzeigenBot) -> None: + """Ensure fetch-conversation command records the conversation id""" + bot.parse_args(["app", "fetch-conversation", "--conversation-id", "abc123"]) + assert bot.command == "fetch-conversation" + assert bot.conversation_id == "abc123" + + def test_parse_args_fetch_conversation_requires_id(self, bot:KleinanzeigenBot) -> None: + """Missing conversation id should exit""" + with pytest.raises(SystemExit): + bot.parse_args(["app", "fetch-conversation"]) + + def test_parse_args_fetch_conversations(self, bot:KleinanzeigenBot) -> None: + """Test parsing of fetch-conversations command with custom limit""" + bot.parse_args(["app", "fetch-conversations", "--limit=5"]) + assert bot.command == "fetch-conversations" + assert bot.conversation_limit == 5 + + def test_parse_args_fetch_conversations_requires_positive_limit(self, bot:KleinanzeigenBot) -> None: + """Ensure fetch-conversations command validates positive limit""" + with pytest.raises(SystemExit): + bot.parse_args(["app", "fetch-conversations", "--limit=0"]) + def test_create_default_config_logs_error_if_exists(self, tmp_path:pathlib.Path, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: """Test that create_default_config logs an error if the config file already exists.""" config_path = tmp_path / "config.yaml"