Skip to content
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,12 +225,25 @@ Options:
--logfile=<PATH> - 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=<LISTING_URL> (message) - Kleinanzeigen listing URL to message
--text=<MESSAGE> (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/<listing-id>" \
--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.

## <a name="config"></a>Configuration

All configuration files can be in YAML or JSON format.
Expand Down
140 changes: 137 additions & 3 deletions src/kleinanzeigen_bot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use dict


import certifi, colorama, nodriver # isort: skip
from ruamel.yaml import YAML
from wcmatch import glob

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
Expand Down Expand Up @@ -60,6 +61,10 @@ 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.conversation_limit:int = 10
self.conversation_id:str | None = None

def __del__(self) -> None:
if self.file_log:
Expand Down Expand Up @@ -182,6 +187,78 @@ async def run(self, args:list[str]) -> None:
await self.create_browser_session()
await self.login()
await self.download_ads()
case "message":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds quite a lot of new statements and the linter is failing cause of too many statements. Do avoid a larger refactor of the commands you could use a new method to handle the commands in this case. I'm unsure - what do you think @Heavenfighter ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also unsure about the command structure.

self.configure_file_logging()
self.load_config()
# Optional, wie bei anderen Commands:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English please.

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 "<listing-url>" --text "<message>"')
sys.exit(2)

# URL/Text müssen durch parse_args gesetzt sein
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English please.

# if not getattr(self, "message_url", None) or not getattr(self, "message_text", None):
# LOG.error('Usage: kleinanzeigen-bot message --url "<listing-url>" --text "<message>"')
# sys.exit(2)

# genau wie publish/update/delete/download:
await self.create_browser_session()
await self.login()

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:
LOG.info("############################################")
LOG.info("DONE: Message sent.")
LOG.info("############################################")
else:
LOG.info("############################################")
LOG.info("DONE: Message could not be confirmed.")
LOG.info("############################################")

case "fetch-conversations":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All commands have a similar setup - could be extracted to common setup logic.

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)
Expand Down Expand Up @@ -213,6 +290,9 @@ 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
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
Expand Down Expand Up @@ -242,6 +322,8 @@ def show_help(self) -> None:
--logfile=<PATH> - 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=<ZAHL> - (fetch-conversations) Maximale Anzahl abzurufender Unterhaltungen (STANDARD: 10)
--conversation-id=<ID> - (fetch-conversation) ID der abzurufenden Unterhaltung
""".rstrip()))
else:
print(textwrap.dedent(f"""\
Expand All @@ -258,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
Expand Down Expand Up @@ -286,6 +371,8 @@ def show_help(self) -> None:
--logfile=<PATH> - 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=<NUMBER> - (fetch-conversations) maximum number of conversations to fetch (DEFAULT: 10)
--conversation-id=<ID> - (fetch-conversation) ID of the conversation to retrieve
""".rstrip()))

def parse_args(self, args:list[str]) -> None:
Expand All @@ -298,7 +385,11 @@ def parse_args(self, args:list[str]) -> None:
"keep-old",
"logfile=",
"lang=",
"verbose"
"verbose",
"url=",
"text=",
"limit=",
"conversation-id=",
])
except getopt.error as ex:
LOG.error(ex.msg)
Expand Down Expand Up @@ -328,6 +419,18 @@ 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
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:
Expand All @@ -338,6 +441,18 @@ 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 "<listing-url>" --text "<message>"')
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 "<id>"')
sys.exit(2)

def configure_file_logging(self) -> None:
if not self.log_file_path:
return
Expand Down Expand Up @@ -645,17 +760,36 @@ 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():
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():
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"])
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

async def delete_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
count = 0

Expand Down Expand Up @@ -927,7 +1061,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

Expand Down
Loading
Loading