From e8cf10101d0ae9d79312eeaa8c188af0c94797b7 Mon Sep 17 00:00:00 2001
From: Jens <1742418+1cu@users.noreply.github.com>
Date: Fri, 23 Jan 2026 07:36:10 +0100
Subject: [PATCH] feat: integrate XDG paths into bot core (#776)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## ℹ️ Description
Wire XDG path resolution into main bot components.
- Link to the related issue(s): N/A (new feature)
- Integrates installation mode detection into bot core
## 📋 Changes Summary
- Added `finalize_installation_mode()` method for mode detection
- UpdateChecker, AdExtractor now respect installation mode
- Dynamic browser profile defaults (resolved at runtime)
- German translations for installation mode messages
- Comprehensive tests for installation mode integration
**Part 2 of 3 for XDG support**
- Depends on: PR #775 (must be merged first)
- Will rebase on main after merge of previous PR
### ⚙️ Type of Change
- [x] ✨ New feature (adds new functionality without breaking existing
usage)
## ✅ Checklist
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.
By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.
## Summary by CodeRabbit
* **New Features**
* Support for portable and XDG (system-wide) installation modes with
automatic detection and interactive first-run setup.
* Config and paths standardized so app stores config, downloads, logs,
and browser profiles in appropriate locations per mode.
* Update checker improved for more reliable version/commit detection.
* **Chores**
* Moved dependency to runtime: platformdirs added to main dependencies.
* **Tests**
* Added comprehensive tests for installation modes, path utilities, and
related behaviors.
✏️ Tip: You can customize this high-level summary in your review
settings.
---
src/kleinanzeigen_bot/__init__.py | 494 ++++----
src/kleinanzeigen_bot/extract.py | 68 +-
src/kleinanzeigen_bot/model/config_model.py | 247 ++--
.../resources/translations.de.yaml | 7 +-
src/kleinanzeigen_bot/update_checker.py | 41 +-
tests/unit/test_extract.py | 1079 ++++++++---------
tests/unit/test_init.py | 754 ++++++------
7 files changed, 1268 insertions(+), 1422 deletions(-)
diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py
index 077b2b3..87134db 100644
--- a/src/kleinanzeigen_bot/__init__.py
+++ b/src/kleinanzeigen_bot/__init__.py
@@ -19,7 +19,7 @@ from ._version import __version__
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price
from .model.config_model import Config
from .update_checker import UpdateChecker
-from .utils import dicts, error_handlers, loggers, misc
+from .utils import dicts, error_handlers, loggers, misc, xdg_paths
from .utils.exceptions import CaptchaEncountered
from .utils.files import abspath
from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale
@@ -28,7 +28,7 @@ from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin
# W0406: possibly a bug, see https://github.com/PyCQA/pylint/issues/3933
-LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
+LOG: Final[loggers.Logger] = loggers.get_logger(__name__)
LOG.setLevel(loggers.INFO)
colorama.just_fix_windows_console()
@@ -39,7 +39,7 @@ class AdUpdateStrategy(enum.Enum):
MODIFY = enum.auto()
-def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool:
+def _repost_cycle_ready(ad_cfg: Ad, ad_file_relative: str) -> bool:
"""
Check if the repost cycle delay has been satisfied.
@@ -59,23 +59,20 @@ def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool:
ad_file_relative,
max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold
total_reposts,
- applied_cycles
+ applied_cycles,
)
return False
if eligible_cycles <= applied_cycles:
LOG.debug(
- _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"),
- ad_file_relative,
- applied_cycles,
- eligible_cycles
+ _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"), ad_file_relative, applied_cycles, eligible_cycles
)
return False
return True
-def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool:
+def _day_delay_elapsed(ad_cfg: Ad, ad_file_relative: str) -> bool:
"""
Check if the day delay has elapsed since the ad was last published.
@@ -89,11 +86,7 @@ def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool:
reference = ad_cfg.updated_on or ad_cfg.created_on
if not reference:
- LOG.info(
- _("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"),
- ad_file_relative,
- delay_days
- )
+ LOG.info(_("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"), ad_file_relative, delay_days)
return False
# Note: .days truncates to whole days (e.g., 1.9 days -> 1 day)
@@ -101,18 +94,13 @@ def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool:
# Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations
elapsed_days = (misc.now() - reference).days
if elapsed_days < delay_days:
- LOG.info(
- _("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"),
- ad_file_relative,
- delay_days,
- elapsed_days
- )
+ LOG.info(_("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"), ad_file_relative, delay_days, elapsed_days)
return False
return True
-def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
+def apply_auto_price_reduction(ad_cfg: Ad, _ad_cfg_orig: dict[str, Any], ad_file_relative: str) -> None:
"""
Apply automatic price reduction to an ad based on repost count and configuration.
@@ -132,11 +120,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r
return
if ad_cfg.auto_price_reduction.min_price is not None and ad_cfg.auto_price_reduction.min_price == base_price:
- LOG.warning(
- _("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."),
- ad_file_relative,
- base_price
- )
+ LOG.warning(_("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."), ad_file_relative, base_price)
return
if not _repost_cycle_ready(ad_cfg, ad_file_relative):
@@ -148,11 +132,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r
applied_cycles = ad_cfg.price_reduction_count or 0
next_cycle = applied_cycles + 1
- effective_price = calculate_auto_price(
- base_price = base_price,
- auto_price_reduction = ad_cfg.auto_price_reduction,
- target_reduction_cycle = next_cycle
- )
+ effective_price = calculate_auto_price(base_price=base_price, auto_price_reduction=ad_cfg.auto_price_reduction, target_reduction_cycle=next_cycle)
if effective_price is None:
return
@@ -160,28 +140,17 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r
if effective_price == base_price:
# Still increment counter so small fractional reductions can accumulate over multiple cycles
ad_cfg.price_reduction_count = next_cycle
- LOG.info(
- _("Auto price reduction kept price %s after attempting %s reduction cycles"),
- effective_price,
- next_cycle
- )
+ LOG.info(_("Auto price reduction kept price %s after attempting %s reduction cycles"), effective_price, next_cycle)
return
- LOG.info(
- _("Auto price reduction applied: %s -> %s after %s reduction cycles"),
- base_price,
- effective_price,
- next_cycle
- )
+ LOG.info(_("Auto price reduction applied: %s -> %s after %s reduction cycles"), base_price, effective_price, next_cycle)
ad_cfg.price = effective_price
ad_cfg.price_reduction_count = next_cycle
# Note: price_reduction_count is persisted to ad_cfg_orig only after successful publish
class KleinanzeigenBot(WebScrapingMixin):
-
def __init__(self) -> None:
-
# workaround for https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/295
# see https://github.com/pyinstaller/pyinstaller/issues/7229#issuecomment-1309383026
os.environ["SSL_CERT_FILE"] = certifi.where()
@@ -190,14 +159,19 @@ class KleinanzeigenBot(WebScrapingMixin):
self.root_url = "https://www.kleinanzeigen.de"
- self.config:Config
+ self.config: Config
self.config_file_path = abspath("config.yaml")
+ self.config_explicitly_provided = False
- self.categories:dict[str, str] = {}
+ self.installation_mode: xdg_paths.InstallationMode | None = None
- self.file_log:loggers.LogFileHandle | None = None
+ self.categories: dict[str, str] = {}
+
+ self.file_log: loggers.LogFileHandle | None = None
log_file_basename = is_frozen() and os.path.splitext(os.path.basename(sys.executable))[0] or self.__module__
- self.log_file_path:str | None = abspath(f"{log_file_basename}.log")
+ self.log_file_path: str | None = abspath(f"{log_file_basename}.log")
+ self.log_file_basename = log_file_basename
+ self.log_file_explicitly_provided = False
self.command = "help"
self.ads_selector = "due"
@@ -209,11 +183,71 @@ class KleinanzeigenBot(WebScrapingMixin):
self.file_log = None
self.close_browser_session()
+ @property
+ def installation_mode_or_portable(self) -> xdg_paths.InstallationMode:
+ return self.installation_mode or "portable"
+
def get_version(self) -> str:
return __version__
- async def run(self, args:list[str]) -> None:
+ def finalize_installation_mode(self) -> None:
+ """
+ Finalize installation mode detection after CLI args are parsed.
+ Must be called after parse_args() to respect --config overrides.
+ """
+ if self.command in {"help", "version"}:
+ return
+ # Check if config_file_path was already customized (by --config or tests)
+ default_portable_config = xdg_paths.get_config_file_path("portable").resolve()
+ config_path = Path(self.config_file_path).resolve() if self.config_file_path else None
+ config_was_customized = self.config_explicitly_provided or (config_path is not None and config_path != default_portable_config)
+
+ if config_was_customized and self.config_file_path:
+ # Config path was explicitly set - detect mode based on it
+ LOG.debug("Detecting installation mode from explicit config path: %s", self.config_file_path)
+
+ if config_path is not None and config_path == (Path.cwd() / "config.yaml").resolve():
+ # Explicit path points to CWD config
+ self.installation_mode = "portable"
+ LOG.debug("Explicit config is in CWD, using portable mode")
+ elif config_path is not None and config_path.is_relative_to(xdg_paths.get_xdg_base_dir("config").resolve()):
+ # Explicit path is within XDG config directory
+ self.installation_mode = "xdg"
+ LOG.debug("Explicit config is in XDG directory, using xdg mode")
+ else:
+ # Custom location - default to portable mode (all paths relative to config)
+ self.installation_mode = "portable"
+ LOG.debug("Explicit config is in custom location, defaulting to portable mode")
+ else:
+ # No explicit config - use auto-detection
+ LOG.debug("Detecting installation mode...")
+ self.installation_mode = xdg_paths.detect_installation_mode()
+
+ if self.installation_mode is None:
+ # First run - prompt user
+ LOG.info(_("First run detected, prompting user for installation mode"))
+ self.installation_mode = xdg_paths.prompt_installation_mode()
+
+ # Set config path based on detected mode
+ self.config_file_path = str(xdg_paths.get_config_file_path(self.installation_mode))
+
+ # Set log file path based on mode (unless explicitly overridden via --logfile)
+ using_default_portable_log = (
+ self.log_file_path is not None and Path(self.log_file_path).resolve() == xdg_paths.get_log_file_path(self.log_file_basename, "portable").resolve()
+ )
+ if not self.log_file_explicitly_provided and using_default_portable_log:
+ # Still using default portable path - update to match detected mode
+ self.log_file_path = str(xdg_paths.get_log_file_path(self.log_file_basename, self.installation_mode))
+ LOG.debug("Log file path: %s", self.log_file_path)
+
+ # Log installation mode and config location (INFO level for user visibility)
+ mode_display = "portable (current directory)" if self.installation_mode == "portable" else "system-wide (XDG directories)"
+ LOG.info(_("Installation mode: %s"), mode_display)
+ LOG.info(_("Config file: %s"), self.config_file_path)
+
+ async def run(self, args: list[str]) -> None:
self.parse_args(args)
+ self.finalize_installation_mode()
try:
match self.command:
case "help":
@@ -233,7 +267,7 @@ class KleinanzeigenBot(WebScrapingMixin):
self.configure_file_logging()
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
self.load_ads()
LOG.info("############################################")
@@ -242,16 +276,16 @@ class KleinanzeigenBot(WebScrapingMixin):
case "update-check":
self.configure_file_logging()
self.load_config()
- checker = UpdateChecker(self.config)
- checker.check_for_updates(skip_interval_check = True)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
+ checker.check_for_updates(skip_interval_check=True)
case "update-content-hash":
self.configure_file_logging()
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
self.ads_selector = "all"
- if ads := self.load_ads(exclude_ads_with_id = False):
+ if ads := self.load_ads(exclude_ads_with_id=False):
self.update_content_hashes(ads)
else:
LOG.info("############################################")
@@ -261,12 +295,14 @@ class KleinanzeigenBot(WebScrapingMixin):
self.configure_file_logging()
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
- if not (self.ads_selector in {"all", "new", "due", "changed"} or
- any(selector in self.ads_selector.split(",") for selector in ("all", "new", "due", "changed")) or
- re.compile(r"\d+[,\d+]*").search(self.ads_selector)):
+ if not (
+ self.ads_selector in {"all", "new", "due", "changed"}
+ or any(selector in self.ads_selector.split(",") for selector in ("all", "new", "due", "changed"))
+ or re.compile(r"\d+[,\d+]*").search(self.ads_selector)
+ ):
LOG.warning('You provided no ads selector. Defaulting to "due".')
self.ads_selector = "due"
@@ -282,10 +318,11 @@ class KleinanzeigenBot(WebScrapingMixin):
self.configure_file_logging()
self.load_config()
- if not (self.ads_selector in {"all", "changed"} or
- any(selector in self.ads_selector.split(",") for selector in
- ("all", "changed")) or
- re.compile(r"\d+[,\d+]*").search(self.ads_selector)):
+ if not (
+ self.ads_selector in {"all", "changed"}
+ or any(selector in self.ads_selector.split(",") for selector in ("all", "changed"))
+ or re.compile(r"\d+[,\d+]*").search(self.ads_selector)
+ ):
LOG.warning('You provided no ads selector. Defaulting to "changed".')
self.ads_selector = "changed"
@@ -301,7 +338,7 @@ class KleinanzeigenBot(WebScrapingMixin):
self.configure_file_logging()
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
if ads := self.load_ads():
await self.create_browser_session()
@@ -315,7 +352,7 @@ class KleinanzeigenBot(WebScrapingMixin):
self.configure_file_logging()
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
# Default to all ads if no selector provided
@@ -339,7 +376,7 @@ class KleinanzeigenBot(WebScrapingMixin):
self.ads_selector = "new"
self.load_config()
# Check for updates on startup
- checker = UpdateChecker(self.config)
+ checker = UpdateChecker(self.config, self.installation_mode_or_portable)
checker.check_for_updates()
await self.create_browser_session()
await self.login()
@@ -360,7 +397,9 @@ class KleinanzeigenBot(WebScrapingMixin):
exe = "python -m kleinanzeigen_bot"
if get_current_locale().language == "de":
- print(textwrap.dedent(f"""\
+ print(
+ textwrap.dedent(
+ f"""\
Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL}
Befehle:
@@ -408,9 +447,13 @@ class KleinanzeigenBot(WebScrapingMixin):
--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
- """.rstrip()))
+ """.rstrip()
+ )
+ )
else:
- print(textwrap.dedent(f"""\
+ print(
+ textwrap.dedent(
+ f"""\
Usage: {colorama.Fore.LIGHTMAGENTA_EX}{exe} COMMAND [OPTIONS]{colorama.Style.RESET_ALL}
Commands:
@@ -456,20 +499,13 @@ class KleinanzeigenBot(WebScrapingMixin):
--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
- """.rstrip()))
+ """.rstrip()
+ )
+ )
- def parse_args(self, args:list[str]) -> None:
+ def parse_args(self, args: list[str]) -> None:
try:
- options, arguments = getopt.gnu_getopt(args[1:], "hv", [
- "ads=",
- "config=",
- "force",
- "help",
- "keep-old",
- "logfile=",
- "lang=",
- "verbose"
- ])
+ options, arguments = getopt.gnu_getopt(args[1:], "hv", ["ads=", "config=", "force", "help", "keep-old", "logfile=", "lang=", "verbose"])
except getopt.error as ex:
LOG.error(ex.msg)
LOG.error("Use --help to display available options.")
@@ -482,11 +518,13 @@ class KleinanzeigenBot(WebScrapingMixin):
sys.exit(0)
case "--config":
self.config_file_path = abspath(value)
+ self.config_explicitly_provided = True
case "--logfile":
if value:
self.log_file_path = abspath(value)
else:
self.log_file_path = None
+ self.log_file_explicitly_provided = True
case "--ads":
self.ads_selector = value.strip().lower()
case "--force":
@@ -533,12 +571,12 @@ class KleinanzeigenBot(WebScrapingMixin):
default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password
dicts.save_dict(
self.config_file_path,
- default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}),
- header = (
+ default_config.model_dump(exclude_none=True, exclude={"ad_defaults": {"description"}}),
+ header=(
"# yaml-language-server: $schema="
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot"
"/refs/heads/main/schemas/config.schema.json"
- )
+ ),
)
def load_config(self) -> None:
@@ -547,7 +585,7 @@ class KleinanzeigenBot(WebScrapingMixin):
self.create_default_config()
config_yaml = dicts.load_dict_if_exists(self.config_file_path, _("config"))
- self.config = Config.model_validate(config_yaml, strict = True, context = self.config_file_path)
+ self.config = Config.model_validate(config_yaml, strict=True, context=self.config_file_path)
# load built-in category mappings
self.categories = dicts.load_dict_from_module(resources, "categories.yaml", "categories")
@@ -560,13 +598,13 @@ class KleinanzeigenBot(WebScrapingMixin):
# populate browser_config object used by WebScrapingMixin
self.browser_config.arguments = self.config.browser.arguments
self.browser_config.binary_location = self.config.browser.binary_location
- self.browser_config.extensions = [abspath(item, relative_to = self.config_file_path) for item in self.config.browser.extensions]
+ self.browser_config.extensions = [abspath(item, relative_to=self.config_file_path) for item in self.config.browser.extensions]
self.browser_config.use_private_window = self.config.browser.use_private_window
if self.config.browser.user_data_dir:
- self.browser_config.user_data_dir = abspath(self.config.browser.user_data_dir, relative_to = self.config_file_path)
+ self.browser_config.user_data_dir = abspath(self.config.browser.user_data_dir, relative_to=self.config_file_path)
self.browser_config.profile_name = self.config.browser.profile_name
- def __check_ad_republication(self, ad_cfg:Ad, ad_file_relative:str) -> bool:
+ def __check_ad_republication(self, ad_cfg: Ad, ad_file_relative: str) -> bool:
"""
Check if an ad needs to be republished based on republication interval.
Note: This method does not check for content changes. Use __check_ad_changed for that.
@@ -591,13 +629,13 @@ class KleinanzeigenBot(WebScrapingMixin):
" -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days",
ad_file_relative,
ad_age.days,
- ad_cfg.republication_interval
+ ad_cfg.republication_interval,
)
return False
return True
- def __check_ad_changed(self, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> bool:
+ def __check_ad_changed(self, ad_cfg: Ad, ad_cfg_orig: dict[str, Any], ad_file_relative: str) -> bool:
"""
Check if an ad has been changed since last publication.
@@ -624,7 +662,7 @@ class KleinanzeigenBot(WebScrapingMixin):
return False
- def load_ads(self, *, ignore_inactive:bool = True, exclude_ads_with_id:bool = True) -> list[tuple[str, Ad, dict[str, Any]]]:
+ def load_ads(self, *, ignore_inactive: bool = True, exclude_ads_with_id: bool = True) -> list[tuple[str, Ad, dict[str, Any]]]:
"""
Load and validate all ad config files, optionally filtering out inactive or already-published ads.
@@ -640,12 +678,12 @@ class KleinanzeigenBot(WebScrapingMixin):
"""
LOG.info("Searching for ad config files...")
- ad_files:dict[str, str] = {}
+ ad_files: dict[str, str] = {}
data_root_dir = os.path.dirname(self.config_file_path)
for file_pattern in self.config.ad_files:
- for ad_file in glob.glob(file_pattern, root_dir = data_root_dir, flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB):
+ for ad_file in glob.glob(file_pattern, root_dir=data_root_dir, flags=glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB):
if not str(ad_file).endswith("ad_fields.yaml"):
- ad_files[abspath(ad_file, relative_to = data_root_dir)] = ad_file
+ ad_files[abspath(ad_file, relative_to=data_root_dir)] = ad_file
LOG.info(" -> found %s", pluralize("ad config file", ad_files))
if not ad_files:
return []
@@ -662,8 +700,8 @@ class KleinanzeigenBot(WebScrapingMixin):
ads = []
for ad_file, ad_file_relative in sorted(ad_files.items()):
- ad_cfg_orig:dict[str, Any] = dicts.load_dict(ad_file, "ad")
- ad_cfg:Ad = self.load_ad(ad_cfg_orig)
+ ad_cfg_orig: dict[str, Any] = dicts.load_dict(ad_file, "ad")
+ ad_cfg: Ad = self.load_ad(ad_cfg_orig)
if ignore_inactive and not ad_cfg.active:
LOG.info(" -> SKIPPED: inactive ad [%s]", ad_file_relative)
@@ -700,8 +738,8 @@ class KleinanzeigenBot(WebScrapingMixin):
if not should_include:
continue
- ensure(self.__get_description(ad_cfg, with_affixes = False), f"-> property [description] not specified @ [{ad_file}]")
- self.__get_description(ad_cfg, with_affixes = True) # validates complete description
+ ensure(self.__get_description(ad_cfg, with_affixes=False), f"-> property [description] not specified @ [{ad_file}]")
+ self.__get_description(ad_cfg, with_affixes=True) # validates complete description
if ad_cfg.category:
resolved_category_id = self.categories.get(ad_cfg.category)
@@ -710,9 +748,7 @@ class KleinanzeigenBot(WebScrapingMixin):
parent_category = ad_cfg.category.rpartition(">")[0].strip()
resolved_category_id = self.categories.get(parent_category)
if resolved_category_id:
- LOG.warning(
- "Category [%s] unknown. Using category [%s] with ID [%s] instead.",
- ad_cfg.category, parent_category, resolved_category_id)
+ LOG.warning("Category [%s] unknown. Using category [%s] with ID [%s] instead.", ad_cfg.category, parent_category, resolved_category_id)
if resolved_category_id:
ad_cfg.category = resolved_category_id
@@ -722,34 +758,29 @@ class KleinanzeigenBot(WebScrapingMixin):
ad_dir = os.path.dirname(ad_file)
for image_pattern in ad_cfg.images:
pattern_images = set()
- for image_file in glob.glob(image_pattern, root_dir = ad_dir, flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB):
+ for image_file in glob.glob(image_pattern, root_dir=ad_dir, flags=glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB):
_, image_file_ext = os.path.splitext(image_file)
ensure(image_file_ext.lower() in {".gif", ".jpg", ".jpeg", ".png"}, f"Unsupported image file type [{image_file}]")
if os.path.isabs(image_file):
pattern_images.add(image_file)
else:
- pattern_images.add(abspath(image_file, relative_to = ad_file))
+ pattern_images.add(abspath(image_file, relative_to=ad_file))
images.extend(sorted(pattern_images))
ensure(images or not ad_cfg.images, f"No images found for given file patterns {ad_cfg.images} at {ad_dir}")
ad_cfg.images = list(dict.fromkeys(images))
- ads.append((
- ad_file,
- ad_cfg,
- ad_cfg_orig
- ))
+ ads.append((ad_file, ad_cfg, ad_cfg_orig))
LOG.info("Loaded %s", pluralize("ad", ads))
return ads
- def load_ad(self, ad_cfg_orig:dict[str, Any]) -> Ad:
+ def load_ad(self, ad_cfg_orig: dict[str, Any]) -> Ad:
return AdPartial.model_validate(ad_cfg_orig).to_ad(self.config.ad_defaults)
- async def check_and_wait_for_captcha(self, *, is_login_page:bool = True) -> None:
+ async def check_and_wait_for_captcha(self, *, is_login_page: bool = True) -> None:
try:
captcha_timeout = self._timeout("captcha_detection")
- await self.web_find(By.CSS_SELECTOR,
- "iframe[name^='a-'][src^='https://www.google.com/recaptcha/api2/anchor?']", timeout = captcha_timeout)
+ await self.web_find(By.CSS_SELECTOR, "iframe[name^='a-'][src^='https://www.google.com/recaptcha/api2/anchor?']", timeout=captcha_timeout)
if not is_login_page and self.config.captcha.auto_restart:
LOG.warning("Captcha recognized - auto-restart enabled, abort run...")
@@ -802,14 +833,14 @@ class KleinanzeigenBot(WebScrapingMixin):
await self.web_input(By.ID, "login-password", "")
await self.web_input(By.ID, "login-password", self.config.login.password)
- await self.check_and_wait_for_captcha(is_login_page = True)
+ await self.check_and_wait_for_captcha(is_login_page=True)
await self.web_click(By.CSS_SELECTOR, "form#login-form button[type='submit']")
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)
+ 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("############################################")
@@ -821,11 +852,11 @@ class KleinanzeigenBot(WebScrapingMixin):
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_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)
+ await self.web_click(
+ By.XPATH, "//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]", timeout=gdpr_timeout
+ )
except TimeoutError:
# GDPR banner not shown within timeout.
pass
@@ -842,7 +873,7 @@ class KleinanzeigenBot(WebScrapingMixin):
# Try to find the standard element first
try:
- user_info = await self.web_text(By.CLASS_NAME, "mr-medium", timeout = login_check_timeout)
+ user_info = await self.web_text(By.CLASS_NAME, "mr-medium", timeout=login_check_timeout)
if username in user_info.lower():
LOG.debug(_("Login detected via .mr-medium element"))
return True
@@ -851,7 +882,7 @@ class KleinanzeigenBot(WebScrapingMixin):
# If standard element not found or didn't contain username, try the alternative
try:
- user_info = await self.web_text(By.ID, "user-email", timeout = login_check_timeout)
+ user_info = await self.web_text(By.ID, "user-email", timeout=login_check_timeout)
if username in user_info.lower():
LOG.debug(_("Login detected via #user-email element"))
return True
@@ -861,23 +892,22 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.debug(_("No login detected - neither .mr-medium nor #user-email found with username"))
return False
- async def delete_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
+ async def delete_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None:
count = 0
- published_ads = json.loads(
- (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
+ published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
- for (ad_file, ad_cfg, _ad_cfg_orig) in ad_cfgs:
+ for ad_file, ad_cfg, _ad_cfg_orig in ad_cfgs:
count += 1
LOG.info("Processing %s/%s: '%s' from [%s]...", count, len(ad_cfgs), ad_cfg.title, ad_file)
- await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title)
+ await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=self.config.publishing.delete_old_ads_by_title)
await self.web_sleep()
LOG.info("############################################")
LOG.info("DONE: Deleted %s", pluralize("ad", count))
LOG.info("############################################")
- async def delete_ad(self, ad_cfg:Ad, published_ads:list[dict[str, Any]], *, delete_old_ads_by_title:bool) -> bool:
+ async def delete_ad(self, ad_cfg: Ad, published_ads: list[dict[str, Any]], *, delete_old_ads_by_title: bool) -> bool:
LOG.info("Deleting ad '%s' if already present...", ad_cfg.title)
await self.web_open(f"{self.root_url}/m-meine-anzeigen.html")
@@ -886,38 +916,34 @@ class KleinanzeigenBot(WebScrapingMixin):
ensure(csrf_token is not None, "Expected CSRF Token not found in HTML content!")
if delete_old_ads_by_title:
-
for published_ad in published_ads:
published_ad_id = int(published_ad.get("id", -1))
published_ad_title = published_ad.get("title", "")
if ad_cfg.id == published_ad_id or ad_cfg.title == published_ad_title:
LOG.info(" -> deleting %s '%s'...", published_ad_id, published_ad_title)
await self.web_request(
- url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={published_ad_id}",
- method = "POST",
- headers = {"x-csrf-token": str(csrf_token)}
+ url=f"{self.root_url}/m-anzeigen-loeschen.json?ids={published_ad_id}", method="POST", headers={"x-csrf-token": str(csrf_token)}
)
elif ad_cfg.id:
await self.web_request(
- url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={ad_cfg.id}",
- method = "POST",
- headers = {"x-csrf-token": str(csrf_token)},
- valid_response_codes = [200, 404]
+ url=f"{self.root_url}/m-anzeigen-loeschen.json?ids={ad_cfg.id}",
+ method="POST",
+ headers={"x-csrf-token": str(csrf_token)},
+ valid_response_codes=[200, 404],
)
await self.web_sleep()
ad_cfg.id = None
return True
- async def extend_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
+ async def extend_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None:
"""Extends ads that are close to expiry."""
# Fetch currently published ads from API
- published_ads = json.loads(
- (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
+ published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
# Filter ads that need extension
ads_to_extend = []
- for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs:
+ for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs:
# Skip unpublished ads (no ID)
if not ad_cfg.id:
LOG.info(_(" -> SKIPPED: ad '%s' is not published yet"), ad_cfg.title)
@@ -949,8 +975,7 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.info(_(" -> ad '%s' expires in %d days, will extend"), ad_cfg.title, days_until_expiry)
ads_to_extend.append((ad_file, ad_cfg, ad_cfg_orig, published_ad))
else:
- LOG.info(_(" -> SKIPPED: ad '%s' expires in %d days (can only extend within 8 days)"),
- ad_cfg.title, days_until_expiry)
+ LOG.info(_(" -> SKIPPED: ad '%s' expires in %d days (can only extend within 8 days)"), ad_cfg.title, days_until_expiry)
if not ads_to_extend:
LOG.info(_("No ads need extension at this time."))
@@ -961,7 +986,7 @@ class KleinanzeigenBot(WebScrapingMixin):
# Process extensions
success_count = 0
- for idx, (ad_file, ad_cfg, ad_cfg_orig, _published_ad) in enumerate(ads_to_extend, start = 1):
+ for idx, (ad_file, ad_cfg, ad_cfg_orig, _published_ad) in enumerate(ads_to_extend, start=1):
LOG.info(_("Processing %s/%s: '%s' from [%s]..."), idx, len(ads_to_extend), ad_cfg.title, ad_file)
if await self.extend_ad(ad_file, ad_cfg, ad_cfg_orig):
success_count += 1
@@ -971,7 +996,7 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.info(_("DONE: Extended %s"), pluralize("ad", success_count))
LOG.info("############################################")
- async def extend_ad(self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any]) -> bool:
+ async def extend_ad(self, ad_file: str, ad_cfg: Ad, ad_cfg_orig: dict[str, Any]) -> bool:
"""Extends a single ad listing."""
LOG.info(_("Extending ad '%s' (ID: %s)..."), ad_cfg.title, ad_cfg.id)
@@ -996,14 +1021,14 @@ class KleinanzeigenBot(WebScrapingMixin):
# Simply close the dialog with the X button (aria-label="Schließen")
try:
dialog_close_timeout = self._timeout("quick_dom")
- await self.web_click(By.CSS_SELECTOR, 'button[aria-label="Schließen"]', timeout = dialog_close_timeout)
+ await self.web_click(By.CSS_SELECTOR, 'button[aria-label="Schließen"]', timeout=dialog_close_timeout)
LOG.debug(" -> Closed confirmation dialog")
except TimeoutError:
LOG.warning(_(" -> No confirmation dialog found, extension may have completed directly"))
# Update metadata in YAML file
# Update updated_on to track when ad was extended
- ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec = "seconds")
+ ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec="seconds")
dicts.save_dict(ad_file, ad_cfg_orig)
LOG.info(_(" -> SUCCESS: ad extended with ID %s"), ad_cfg.id)
@@ -1020,15 +1045,14 @@ class KleinanzeigenBot(WebScrapingMixin):
# Check for success messages
return await self.web_check(By.ID, "checking-done", Is.DISPLAYED) or await self.web_check(By.ID, "not-completed", Is.DISPLAYED)
- async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
+ async def publish_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None:
count = 0
failed_count = 0
max_retries = 3
- published_ads = json.loads(
- (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
+ published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
- for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs:
+ for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs:
LOG.info("Processing %s/%s: '%s' from [%s]...", count + 1, len(ad_cfgs), ad_cfg.title, ad_file)
if [x for x in published_ads if x["id"] == ad_cfg.id and x["state"] == "paused"]:
@@ -1058,12 +1082,12 @@ class KleinanzeigenBot(WebScrapingMixin):
if success:
try:
publish_timeout = self._timeout("publishing_result")
- await self.web_await(self.__check_publishing_result, timeout = publish_timeout)
+ await self.web_await(self.__check_publishing_result, timeout=publish_timeout)
except TimeoutError:
LOG.warning(_(" -> Could not confirm publishing for '%s', but ad may be online"), ad_cfg.title)
if success and self.config.publishing.delete_old_ads == "AFTER_PUBLISH" and not self.keep_old_ads:
- await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False)
+ await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=False)
LOG.info("############################################")
if failed_count > 0:
@@ -1072,8 +1096,9 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.info(_("DONE: (Re-)published %s"), pluralize("ad", count))
LOG.info("############################################")
- async def publish_ad(self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], published_ads:list[dict[str, Any]],
- mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
+ async def publish_ad(
+ 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
@@ -1083,7 +1108,7 @@ class KleinanzeigenBot(WebScrapingMixin):
if mode == AdUpdateStrategy.REPLACE:
if self.config.publishing.delete_old_ads == "BEFORE_PUBLISH" and not self.keep_old_ads:
- await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title)
+ await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title=self.config.publishing.delete_old_ads_by_title)
# Apply auto price reduction only for REPLACE operations (actual reposts)
# This ensures price reductions only happen on republish, not on UPDATE
@@ -1172,12 +1197,12 @@ class KleinanzeigenBot(WebScrapingMixin):
elif not await self.web_check(By.ID, "radio-buy-now-no", Is.SELECTED):
await self.web_click(By.ID, "radio-buy-now-no")
except TimeoutError as ex:
- LOG.debug(ex, exc_info = True)
+ LOG.debug(ex, exc_info=True)
#############################
# set description
#############################
- description = self.__get_description(ad_cfg, with_affixes = True)
+ description = self.__get_description(ad_cfg, with_affixes=True)
await self.web_execute("document.querySelector('#pstad-descrptn').value = `" + description.replace("`", "'") + "`")
await self.__set_contact_fields(ad_cfg.contact)
@@ -1186,10 +1211,9 @@ class KleinanzeigenBot(WebScrapingMixin):
#############################
# delete previous images because we don't know which have changed
#############################
- img_items = await self.web_find_all(By.CSS_SELECTOR,
- "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)")
+ img_items = await self.web_find_all(By.CSS_SELECTOR, "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)")
for element in img_items:
- btn = await self.web_find(By.CSS_SELECTOR, "button.pictureupload-thumbnails-remove", parent = element)
+ btn = await self.web_find(By.CSS_SELECTOR, "button.pictureupload-thumbnails-remove", parent=element)
await btn.click()
#############################
@@ -1200,7 +1224,7 @@ class KleinanzeigenBot(WebScrapingMixin):
#############################
# wait for captcha
#############################
- await self.check_and_wait_for_captcha(is_login_page = False)
+ await self.check_and_wait_for_captcha(is_login_page=False)
#############################
# submit
@@ -1226,7 +1250,7 @@ class KleinanzeigenBot(WebScrapingMixin):
#############################
try:
short_timeout = self._timeout("quick_dom")
- await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout)
+ await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout=short_timeout)
LOG.warning("############################################")
LOG.warning("# Payment form detected! Please proceed with payment.")
@@ -1238,7 +1262,7 @@ class KleinanzeigenBot(WebScrapingMixin):
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)
+ await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout=confirmation_timeout)
# 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)
@@ -1248,7 +1272,7 @@ class KleinanzeigenBot(WebScrapingMixin):
# Update content hash after successful publication
# Calculate hash on original config to ensure consistent comparison on restart
ad_cfg_orig["content_hash"] = AdPartial.model_validate(ad_cfg_orig).update_content_hash().content_hash
- ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec = "seconds")
+ ad_cfg_orig["updated_on"] = misc.now().isoformat(timespec="seconds")
if not ad_cfg.created_on and not ad_cfg.id:
ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"]
@@ -1275,7 +1299,7 @@ class KleinanzeigenBot(WebScrapingMixin):
dicts.save_dict(ad_file, ad_cfg_orig)
- async def __set_contact_fields(self, contact:Contact) -> None:
+ async def __set_contact_fields(self, contact: Contact) -> None:
#############################
# set contact zipcode
#############################
@@ -1354,11 +1378,13 @@ class KleinanzeigenBot(WebScrapingMixin):
await self.web_input(By.ID, "postad-phonenumber", contact.phone)
except TimeoutError:
LOG.warning(
- _("Phone number field not present on page. This is expected for many private accounts; "
- "commercial accounts may still support phone numbers.")
+ _(
+ "Phone number field not present on page. This is expected for many private accounts; "
+ "commercial accounts may still support phone numbers."
+ )
)
- async def update_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
+ async def update_ads(self, ad_cfgs: list[tuple[str, Ad, dict[str, Any]]]) -> None:
"""
Updates a list of ads.
The list gets filtered, so that only already published ads will be updated.
@@ -1372,10 +1398,9 @@ class KleinanzeigenBot(WebScrapingMixin):
"""
count = 0
- published_ads = json.loads(
- (await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
+ published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"]
- for (ad_file, ad_cfg, ad_cfg_orig) in ad_cfgs:
+ for ad_file, ad_cfg, ad_cfg_orig in ad_cfgs:
ad = next((ad for ad in published_ads if ad["id"] == ad_cfg.id), None)
if not ad:
@@ -1390,25 +1415,25 @@ class KleinanzeigenBot(WebScrapingMixin):
await self.publish_ad(ad_file, ad_cfg, ad_cfg_orig, published_ads, AdUpdateStrategy.MODIFY)
publish_timeout = self._timeout("publishing_result")
- await self.web_await(self.__check_publishing_result, timeout = publish_timeout)
+ await self.web_await(self.__check_publishing_result, timeout=publish_timeout)
LOG.info("############################################")
LOG.info("DONE: updated %s", pluralize("ad", count))
LOG.info("############################################")
- async def __set_condition(self, condition_value:str) -> None:
+ async def __set_condition(self, condition_value: str) -> None:
try:
# Open condition dialog
await self.web_click(By.XPATH, '//*[@id="j-post-listing-frontend-conditions"]//button[@aria-haspopup="true"]')
except TimeoutError:
- LOG.debug("Unable to open condition dialog and select condition [%s]", condition_value, exc_info = True)
+ LOG.debug("Unable to open condition dialog and select condition [%s]", condition_value, exc_info=True)
return
try:
# Click radio button
await self.web_click(By.ID, f"radio-button-{condition_value}")
except TimeoutError:
- LOG.debug("Unable to select condition [%s]", condition_value, exc_info = True)
+ LOG.debug("Unable to select condition [%s]", condition_value, exc_info=True)
try:
# Click accept button
@@ -1416,7 +1441,7 @@ class KleinanzeigenBot(WebScrapingMixin):
except TimeoutError as ex:
raise TimeoutError(_("Unable to close condition dialog!")) from ex
- async def __set_category(self, category:str | None, ad_file:str) -> None:
+ async def __set_category(self, category: str | None, ad_file: str) -> None:
# click on something to trigger automatic category detection
await self.web_click(By.ID, "pstad-descrptn")
@@ -1439,7 +1464,7 @@ class KleinanzeigenBot(WebScrapingMixin):
else:
ensure(is_category_auto_selected, f"No category specified in [{ad_file}] and automatic category detection failed")
- async def __set_special_attributes(self, ad_cfg:Ad) -> None:
+ async def __set_special_attributes(self, ad_cfg: Ad) -> None:
if not ad_cfg.special_attributes:
return
@@ -1474,7 +1499,7 @@ class KleinanzeigenBot(WebScrapingMixin):
raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex
try:
- elem_id:str = str(special_attr_elem.attrs.id)
+ elem_id: str = str(special_attr_elem.attrs.id)
if special_attr_elem.local_name == "select":
LOG.debug(_("Attribute field '%s' seems to be a select..."), special_attribute_key)
await self.web_select(By.ID, elem_id, special_attribute_value_str)
@@ -1492,27 +1517,26 @@ class KleinanzeigenBot(WebScrapingMixin):
raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex
LOG.debug("Successfully set attribute field [%s] to [%s]...", special_attribute_key, special_attribute_value_str)
- async def __set_shipping(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
+ async def __set_shipping(self, ad_cfg: Ad, mode: AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
short_timeout = self._timeout("quick_dom")
if ad_cfg.shipping_type == "PICKUP":
try:
await self.web_click(By.ID, "radio-pickup")
except TimeoutError as ex:
- LOG.debug(ex, exc_info = True)
+ LOG.debug(ex, exc_info=True)
elif ad_cfg.shipping_options:
await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]')
if mode == AdUpdateStrategy.MODIFY:
try:
# when "Andere Versandmethoden" is not available, go back and start over new
- await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout = short_timeout)
+ await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout=short_timeout)
except TimeoutError:
await self.web_click(By.XPATH, '//dialog//button[contains(., "Zurück")]')
# in some categories we need to go another dialog back
try:
- await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]',
- timeout = short_timeout)
+ await self.web_find(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]', timeout=short_timeout)
except TimeoutError:
await self.web_click(By.XPATH, '//dialog//button[contains(., "Zurück")]')
@@ -1527,12 +1551,10 @@ class KleinanzeigenBot(WebScrapingMixin):
else:
try:
# no options. only costs. Set custom shipping cost
- await self.web_click(By.XPATH,
- '//button//span[contains(., "Versandmethoden auswählen")]')
+ await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]')
try:
# when "Andere Versandmethoden" is not available, then we are already on the individual page
- await self.web_click(By.XPATH,
- '//dialog//button[contains(., "Andere Versandmethoden")]')
+ await self.web_click(By.XPATH, '//dialog//button[contains(., "Andere Versandmethoden")]')
except TimeoutError:
# Dialog option not present; already on the individual shipping page.
pass
@@ -1540,22 +1562,21 @@ class KleinanzeigenBot(WebScrapingMixin):
try:
# only click on "Individueller Versand" when "IndividualShippingInput" is not available, otherwise its already checked
# (important for mode = UPDATE)
- await self.web_find(By.XPATH,
- '//input[contains(@placeholder, "Versandkosten (optional)")]',
- timeout = short_timeout)
+ await self.web_find(By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]', timeout=short_timeout)
except TimeoutError:
# Input not visible yet; click the individual shipping option.
await self.web_click(By.XPATH, '//*[contains(@id, "INDIVIDUAL") and contains(@data-testid, "Individueller Versand")]')
if ad_cfg.shipping_costs is not None:
- await self.web_input(By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]',
- str.replace(str(ad_cfg.shipping_costs), ".", ","))
+ await self.web_input(
+ By.XPATH, '//input[contains(@placeholder, "Versandkosten (optional)")]', str.replace(str(ad_cfg.shipping_costs), ".", ",")
+ )
await self.web_click(By.XPATH, '//dialog//button[contains(., "Fertig")]')
except TimeoutError as ex:
- LOG.debug(ex, exc_info = True)
+ LOG.debug(ex, exc_info=True)
raise TimeoutError(_("Unable to close shipping dialog!")) from ex
- async def __set_shipping_options(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
+ async def __set_shipping_options(self, ad_cfg: Ad, mode: AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
if not ad_cfg.shipping_options:
return
@@ -1575,10 +1596,10 @@ class KleinanzeigenBot(WebScrapingMixin):
except KeyError as ex:
raise KeyError(f"Unknown shipping option(s), please refer to the documentation/README: {ad_cfg.shipping_options}") from ex
- shipping_sizes, shipping_selector, shipping_packages = zip(*mapped_shipping_options, strict = False)
+ shipping_sizes, shipping_selector, shipping_packages = zip(*mapped_shipping_options, strict=False)
try:
- shipping_size, = set(shipping_sizes)
+ (shipping_size,) = set(shipping_sizes)
except ValueError as ex:
raise ValueError("You can only specify shipping options for one package size!") from ex
@@ -1590,8 +1611,7 @@ class KleinanzeigenBot(WebScrapingMixin):
if shipping_size_radio_is_checked:
# in the same size category all options are preselected, so deselect the unwanted ones
unwanted_shipping_packages = [
- package for size, selector, package in shipping_options_mapping.values()
- if size == shipping_size and package not in shipping_packages
+ package for size, selector, package in shipping_options_mapping.values() if size == shipping_size and package not in shipping_packages
]
to_be_clicked_shipping_packages = unwanted_shipping_packages
else:
@@ -1606,10 +1626,7 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.debug("Using MODIFY mode logic for shipping options")
# get only correct size
- selected_size_shipping_packages = [
- package for size, selector, package in shipping_options_mapping.values()
- if size == shipping_size
- ]
+ selected_size_shipping_packages = [package for size, selector, package in shipping_options_mapping.values() if size == shipping_size]
LOG.debug("Processing %d packages for size '%s'", len(selected_size_shipping_packages), shipping_size)
for shipping_package in selected_size_shipping_packages:
@@ -1618,10 +1635,7 @@ class KleinanzeigenBot(WebScrapingMixin):
shipping_package_checkbox_is_checked = hasattr(shipping_package_checkbox.attrs, "checked")
LOG.debug(
- "Package '%s': checked=%s, wanted=%s",
- shipping_package,
- shipping_package_checkbox_is_checked,
- shipping_package in shipping_packages
+ "Package '%s': checked=%s, wanted=%s", shipping_package, shipping_package_checkbox_is_checked, shipping_package in shipping_packages
)
# select wanted packages if not checked already
@@ -1636,23 +1650,21 @@ class KleinanzeigenBot(WebScrapingMixin):
await self.web_click(By.XPATH, shipping_package_xpath)
else:
for shipping_package in to_be_clicked_shipping_packages:
- await self.web_click(
- By.XPATH,
- f'//dialog//input[contains(@data-testid, "{shipping_package}")]')
+ await self.web_click(By.XPATH, f'//dialog//input[contains(@data-testid, "{shipping_package}")]')
except TimeoutError as ex:
- LOG.debug(ex, exc_info = True)
+ LOG.debug(ex, exc_info=True)
try:
# Click apply button
await self.web_click(By.XPATH, '//dialog//button[contains(., "Fertig")]')
except TimeoutError as ex:
raise TimeoutError(_("Unable to close shipping dialog!")) from ex
- async def __upload_images(self, ad_cfg:Ad) -> None:
+ async def __upload_images(self, ad_cfg: Ad) -> None:
if not ad_cfg.images:
return
LOG.info(" -> found %s", pluralize("image", ad_cfg.images))
- image_upload:Element = await self.web_find(By.CSS_SELECTOR, "input[type=file]")
+ image_upload: Element = await self.web_find(By.CSS_SELECTOR, "input[type=file]")
for image in ad_cfg.images:
LOG.info(" -> uploading image [%s]", image)
@@ -1668,7 +1680,7 @@ class KleinanzeigenBot(WebScrapingMixin):
thumbnails = await self.web_find_all(
By.CSS_SELECTOR,
"ul#j-pictureupload-thumbnails > li:not(.is-placeholder)",
- timeout = self._timeout("quick_dom") # Fast timeout for polling
+ timeout=self._timeout("quick_dom"), # Fast timeout for polling
)
current_count = len(thumbnails)
if current_count < expected_count:
@@ -1679,28 +1691,20 @@ class KleinanzeigenBot(WebScrapingMixin):
return False
try:
- await self.web_await(
- check_thumbnails_uploaded,
- timeout = self._timeout("image_upload"),
- timeout_error_message = _("Image upload timeout exceeded")
- )
+ await self.web_await(check_thumbnails_uploaded, timeout=self._timeout("image_upload"), timeout_error_message=_("Image upload timeout exceeded"))
except TimeoutError as ex:
# Get current count for better error message
try:
thumbnails = await self.web_find_all(
- By.CSS_SELECTOR,
- "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)",
- timeout = self._timeout("quick_dom")
+ By.CSS_SELECTOR, "ul#j-pictureupload-thumbnails > li:not(.is-placeholder)", timeout=self._timeout("quick_dom")
)
current_count = len(thumbnails)
except TimeoutError:
# Still no thumbnails after full timeout
current_count = 0
raise TimeoutError(
- _("Not all images were uploaded within timeout. Expected %(expected)d, found %(found)d thumbnails.") % {
- "expected": expected_count,
- "found": current_count
- }
+ _("Not all images were uploaded within timeout. Expected %(expected)d, found %(found)d thumbnails.")
+ % {"expected": expected_count, "found": current_count}
) from ex
LOG.info(_(" -> all images uploaded successfully"))
@@ -1711,7 +1715,7 @@ class KleinanzeigenBot(WebScrapingMixin):
This downloads either all, only unsaved (new), or specific ads given by ID.
"""
- ad_extractor = extract.AdExtractor(self.browser, self.config)
+ ad_extractor = extract.AdExtractor(self.browser, self.config, self.installation_mode_or_portable)
# use relevant download routine
if self.ads_selector in {"all", "new"}: # explore ads overview for these two modes
@@ -1734,19 +1738,16 @@ class KleinanzeigenBot(WebScrapingMixin):
elif self.ads_selector == "new": # download only unsaved ads
# check which ads already saved
saved_ad_ids = []
- ads = self.load_ads(ignore_inactive = False, exclude_ads_with_id = False) # do not skip because of existing IDs
+ ads = self.load_ads(ignore_inactive=False, exclude_ads_with_id=False) # do not skip because of existing IDs
for ad in ads:
saved_ad_id = ad[1].id
if saved_ad_id is None:
- LOG.debug(
- "Skipping saved ad without id (likely unpublished or manually created): %s",
- ad[0]
- )
+ LOG.debug("Skipping saved ad without id (likely unpublished or manually created): %s", ad[0])
continue
saved_ad_ids.append(int(saved_ad_id))
# determine ad IDs from links
- ad_id_by_url = {url:ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls}
+ ad_id_by_url = {url: ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls}
LOG.info("Starting download of not yet downloaded ads...")
new_count = 0
@@ -1774,7 +1775,7 @@ class KleinanzeigenBot(WebScrapingMixin):
else:
LOG.error("The page with the id %d does not exist!", ad_id)
- def __get_description(self, ad_cfg:Ad, *, with_affixes:bool) -> str:
+ 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):
@@ -1797,19 +1798,19 @@ class KleinanzeigenBot(WebScrapingMixin):
# Get prefix with precedence
prefix = (
# 1. Direct ad-level prefix
- ad_cfg.description_prefix if ad_cfg.description_prefix is not None
+ ad_cfg.description_prefix
+ if ad_cfg.description_prefix is not None
# 2. Global prefix from config
- else self.config.ad_defaults.description_prefix
- or "" # Default to empty string if all sources are None
+ else self.config.ad_defaults.description_prefix or "" # Default to empty string if all sources are None
)
# Get suffix with precedence
suffix = (
# 1. Direct ad-level suffix
- ad_cfg.description_suffix if ad_cfg.description_suffix is not None
+ ad_cfg.description_suffix
+ if ad_cfg.description_suffix is not None
# 2. Global suffix from config
- else self.config.ad_defaults.description_suffix
- or "" # Default to empty string if all sources are None
+ else self.config.ad_defaults.description_suffix or "" # Default to empty string if all sources are None
)
# Combine the parts and replace @ with (at)
@@ -1819,16 +1820,17 @@ class KleinanzeigenBot(WebScrapingMixin):
final_description = description_text
# Validate length
- ensure(len(final_description) <= MAX_DESCRIPTION_LENGTH,
- f"Length of ad description including prefix and suffix exceeds {MAX_DESCRIPTION_LENGTH} chars. "
- f"Description length: {len(final_description)} chars.")
+ ensure(
+ len(final_description) <= MAX_DESCRIPTION_LENGTH,
+ f"Length of ad description including prefix and suffix exceeds {MAX_DESCRIPTION_LENGTH} chars. Description length: {len(final_description)} chars.",
+ )
return final_description
- def update_content_hashes(self, ads:list[tuple[str, Ad, dict[str, Any]]]) -> None:
+ def update_content_hashes(self, ads: list[tuple[str, Ad, dict[str, Any]]]) -> None:
count = 0
- for (ad_file, ad_cfg, ad_cfg_orig) in ads:
+ for ad_file, ad_cfg, ad_cfg_orig in ads:
LOG.info("Processing %s/%s: '%s' from [%s]...", count + 1, len(ads), ad_cfg.title, ad_file)
ad_cfg.update_content_hash()
if ad_cfg.content_hash != ad_cfg_orig["content_hash"]:
@@ -1840,14 +1842,16 @@ class KleinanzeigenBot(WebScrapingMixin):
LOG.info("DONE: Updated [content_hash] in %s", pluralize("ad", count))
LOG.info("############################################")
+
#############################
# main entry point
#############################
-def main(args:list[str]) -> None:
+def main(args: list[str]) -> None:
if "version" not in args:
- print(textwrap.dedent(rf"""
+ print(
+ textwrap.dedent(rf"""
_ _ _ _ _ _
| | _| | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_
| |/ / |/ _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __|
@@ -1856,7 +1860,9 @@ def main(args:list[str]) -> None:
|___/
https://github.com/Second-Hand-Friends/kleinanzeigen-bot
Version: {__version__}
- """)[1:], flush = True) # [1:] removes the first empty blank line
+ """)[1:],
+ flush=True,
+ ) # [1:] removes the first empty blank line
loggers.configure_console_logging()
diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py
index d3c5ce0..818c628 100644
--- a/src/kleinanzeigen_bot/extract.py
+++ b/src/kleinanzeigen_bot/extract.py
@@ -15,7 +15,7 @@ from kleinanzeigen_bot.model.ad_model import ContactPartial
from .model.ad_model import AdPartial
from .model.config_model import Config
-from .utils import dicts, files, i18n, loggers, misc, reflect
+from .utils import dicts, files, i18n, loggers, misc, reflect, xdg_paths
from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin
__all__ = [
@@ -33,10 +33,13 @@ class AdExtractor(WebScrapingMixin):
Wrapper class for ad extraction that uses an active bot´s browser session to extract specific elements from an ad page.
"""
- def __init__(self, browser:Browser, config:Config) -> None:
+ def __init__(self, browser:Browser, config:Config, installation_mode:xdg_paths.InstallationMode = "portable") -> None:
super().__init__()
self.browser = browser
self.config:Config = config
+ if installation_mode not in {"portable", "xdg"}:
+ raise ValueError(f"Unsupported installation mode: {installation_mode}")
+ self.installation_mode:xdg_paths.InstallationMode = installation_mode
async def download_ad(self, ad_id:int) -> None:
"""
@@ -47,26 +50,19 @@ class AdExtractor(WebScrapingMixin):
"""
# create sub-directory for ad(s) to download (if necessary):
- relative_directory = Path("downloaded-ads")
- # make sure configured base directory exists (using exist_ok=True to avoid TOCTOU race)
- await asyncio.get_running_loop().run_in_executor(None, lambda: relative_directory.mkdir(exist_ok = True)) # noqa: ASYNC240
- LOG.info("Ensured ads directory exists at ./%s.", relative_directory)
+ download_dir = xdg_paths.get_downloaded_ads_path(self.installation_mode)
+ LOG.info(_("Using download directory: %s"), download_dir)
+ # Note: xdg_paths.get_downloaded_ads_path() already creates the directory
# Extract ad info and determine final directory path
- ad_cfg, final_dir = await self._extract_ad_page_info_with_directory_handling(
- relative_directory, ad_id
- )
+ ad_cfg, final_dir = await self._extract_ad_page_info_with_directory_handling(download_dir, ad_id)
# Save the ad configuration file (offload to executor to avoid blocking the event loop)
ad_file_path = str(Path(final_dir) / f"ad_{ad_id}.yaml")
header_string = (
- "# yaml-language-server: $schema="
- "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
- )
- await asyncio.get_running_loop().run_in_executor(
- None,
- lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string)
+ "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
)
+ await asyncio.get_running_loop().run_in_executor(None, lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string))
@staticmethod
def _download_and_save_image_sync(url:str, directory:str, filename_prefix:str, img_nr:int) -> str | None:
@@ -114,14 +110,7 @@ class AdExtractor(WebScrapingMixin):
if current_img_url is None:
continue
- img_path = await loop.run_in_executor(
- None,
- self._download_and_save_image_sync,
- str(current_img_url),
- directory,
- img_fn_prefix,
- img_nr
- )
+ img_path = await loop.run_in_executor(None, self._download_and_save_image_sync, str(current_img_url), directory, img_fn_prefix, img_nr)
if img_path:
dl_counter += 1
@@ -217,10 +206,7 @@ class AdExtractor(WebScrapingMixin):
# Extract references using the CORRECTED selector
try:
- page_refs:list[str] = [
- str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"])
- for li in list_items
- ]
+ page_refs:list[str] = [str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]) for li in list_items]
refs.extend(page_refs)
LOG.info("Successfully extracted %s refs from page %s.", len(page_refs), current_page)
except Exception as e:
@@ -344,7 +330,7 @@ class AdExtractor(WebScrapingMixin):
if prefix and description_text.startswith(prefix.strip()):
description_text = description_text[len(prefix.strip()):]
if suffix and description_text.endswith(suffix.strip()):
- description_text = description_text[:-len(suffix.strip())]
+ description_text = description_text[: -len(suffix.strip())]
info["description"] = description_text.strip()
@@ -361,8 +347,7 @@ class AdExtractor(WebScrapingMixin):
info["id"] = ad_id
try: # try different locations known for creation date element
- creation_date = await self.web_text(By.XPATH,
- "/html/body/div[1]/div[2]/div/section[2]/section/section/article/div[3]/div[2]/div[2]/div[1]/span")
+ creation_date = await self.web_text(By.XPATH, "/html/body/div[1]/div[2]/div/section[2]/section/section/article/div[3]/div[2]/div[2]/div[1]/span")
except TimeoutError:
creation_date = await self.web_text(By.CSS_SELECTOR, "#viewad-extra-info > div:nth-child(1) > span:nth-child(2)")
@@ -380,9 +365,7 @@ class AdExtractor(WebScrapingMixin):
return ad_cfg
- async def _extract_ad_page_info_with_directory_handling(
- self, relative_directory:Path, ad_id:int
- ) -> tuple[AdPartial, Path]:
+ async def _extract_ad_page_info_with_directory_handling(self, relative_directory:Path, ad_id:int) -> tuple[AdPartial, Path]:
"""
Extracts ad information and handles directory creation/renaming.
@@ -415,8 +398,7 @@ class AdExtractor(WebScrapingMixin):
if await files.exists(temp_dir):
if self.config.download.rename_existing_folders:
# Rename the old folder to the new name with title
- LOG.info("Renaming folder from %s to %s for ad %s...",
- temp_dir.name, final_dir.name, ad_id)
+ LOG.info("Renaming folder from %s to %s for ad %s...", temp_dir.name, final_dir.name, ad_id)
LOG.debug("Renaming: %s -> %s", temp_dir, final_dir)
await loop.run_in_executor(None, temp_dir.rename, final_dir)
else:
@@ -471,14 +453,8 @@ class AdExtractor(WebScrapingMixin):
category_first_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
category_second_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line)
except TimeoutError as exc:
- LOG.error(
- "Legacy breadcrumb selectors not found within %.1f seconds (collected ids: %s)",
- fallback_timeout,
- category_ids
- )
- raise TimeoutError(
- _("Unable to locate breadcrumb fallback selectors within %(seconds).1f seconds.") % {"seconds": fallback_timeout}
- ) from exc
+ LOG.error("Legacy breadcrumb selectors not found within %.1f seconds (collected ids: %s)", fallback_timeout, category_ids)
+ raise TimeoutError(_("Unable to locate breadcrumb fallback selectors within %(seconds).1f seconds.") % {"seconds": fallback_timeout}) from exc
href_first:str = str(category_first_part.attrs["href"])
href_second:str = str(category_second_part.attrs["href"])
cat_num_first_raw = href_first.rsplit("/", maxsplit = 1)[-1]
@@ -553,8 +529,8 @@ class AdExtractor(WebScrapingMixin):
# reading shipping option from kleinanzeigen
# and find the right one by price
shipping_costs = json.loads(
- (await self.web_request("https://gateway.kleinanzeigen.de/postad/api/v1/shipping-options?posterType=PRIVATE"))
- ["content"])["data"]["shippingOptionsResponse"]["options"]
+ (await self.web_request("https://gateway.kleinanzeigen.de/postad/api/v1/shipping-options?posterType=PRIVATE"))["content"]
+ )["data"]["shippingOptionsResponse"]["options"]
# map to internal shipping identifiers used by kleinanzeigen-bot
shipping_option_mapping = {
@@ -566,7 +542,7 @@ class AdExtractor(WebScrapingMixin):
"HERMES_001": "Hermes_Päckchen",
"HERMES_002": "Hermes_S",
"HERMES_003": "Hermes_M",
- "HERMES_004": "Hermes_L"
+ "HERMES_004": "Hermes_L",
}
# Convert Euro to cents and round to nearest integer
diff --git a/src/kleinanzeigen_bot/model/config_model.py b/src/kleinanzeigen_bot/model/config_model.py
index 181be94..9ad7438 100644
--- a/src/kleinanzeigen_bot/model/config_model.py
+++ b/src/kleinanzeigen_bot/model/config_model.py
@@ -15,40 +15,22 @@ from kleinanzeigen_bot.utils import dicts
from kleinanzeigen_bot.utils.misc import get_attr
from kleinanzeigen_bot.utils.pydantics import ContextualModel
-_MAX_PERCENTAGE:Final[int] = 100
+_MAX_PERCENTAGE: Final[int] = 100
class AutoPriceReductionConfig(ContextualModel):
- enabled:bool = Field(
- default = False,
- description = "automatically lower the price of reposted ads"
+ enabled: bool = Field(default=False, description="automatically lower the price of reposted ads")
+ strategy: Literal["FIXED", "PERCENTAGE"] | None = Field(
+ default=None, description="PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount"
)
- strategy:Literal["FIXED", "PERCENTAGE"] | None = Field(
- default = None,
- description = "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount"
- )
- amount:float | None = Field(
- default = None,
- gt = 0,
- description = "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED"
- )
- min_price:float | None = Field(
- default = None,
- ge = 0,
- description = "required when enabled is true; minimum price floor (use 0 for no lower bound)"
- )
- delay_reposts:int = Field(
- default = 0,
- ge = 0,
- description = "number of reposts to wait before applying the first automatic price reduction"
- )
- delay_days:int = Field(
- default = 0,
- ge = 0,
- description = "number of days to wait after publication before applying automatic price reductions"
+ amount: float | None = Field(
+ default=None, gt=0, description="magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED"
)
+ min_price: float | None = Field(default=None, ge=0, description="required when enabled is true; minimum price floor (use 0 for no lower bound)")
+ delay_reposts: int = Field(default=0, ge=0, description="number of reposts to wait before applying the first automatic price reduction")
+ delay_days: int = Field(default=0, ge=0, description="number of days to wait after publication before applying automatic price reductions")
- @model_validator(mode = "after")
+ @model_validator(mode="after")
def _validate_config(self) -> "AutoPriceReductionConfig":
if self.enabled:
if self.strategy is None:
@@ -63,43 +45,38 @@ class AutoPriceReductionConfig(ContextualModel):
class ContactDefaults(ContextualModel):
- name:str | None = None
- street:str | None = None
- zipcode:int | str | None = None
- location:str | None = Field(
- default = None,
- description = "city or locality of the listing (can include multiple districts)",
- examples = ["Sample Town - District One"]
+ name: str | None = None
+ street: str | None = None
+ zipcode: int | str | None = None
+ location: str | None = Field(
+ default=None, description="city or locality of the listing (can include multiple districts)", examples=["Sample Town - District One"]
)
- phone:str | None = None
+ phone: str | None = None
@deprecated("Use description_prefix/description_suffix instead")
class DescriptionAffixes(ContextualModel):
- prefix:str | None = None
- suffix:str | None = None
+ prefix: str | None = None
+ suffix: str | None = None
class AdDefaults(ContextualModel):
- active:bool = True
- type:Literal["OFFER", "WANTED"] = "OFFER"
- description:DescriptionAffixes | None = None
- description_prefix:str | None = Field(default = None, description = "prefix for the ad description")
- description_suffix:str | None = Field(default = None, description = " suffix for the ad description")
- price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE"
- auto_price_reduction:AutoPriceReductionConfig = Field(
- default_factory = AutoPriceReductionConfig,
- description = "automatic price reduction configuration"
- )
- shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING"
- sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect")
- images:list[str] | None = Field(default = None)
- contact:ContactDefaults = Field(default_factory = ContactDefaults)
- republication_interval:int = 7
+ active: bool = True
+ type: Literal["OFFER", "WANTED"] = "OFFER"
+ description: DescriptionAffixes | None = None
+ description_prefix: str | None = Field(default=None, description="prefix for the ad description")
+ description_suffix: str | None = Field(default=None, description=" suffix for the ad description")
+ price_type: Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE"
+ auto_price_reduction: AutoPriceReductionConfig = Field(default_factory=AutoPriceReductionConfig, description="automatic price reduction configuration")
+ shipping_type: Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING"
+ sell_directly: bool = Field(default=False, description="requires shipping_type SHIPPING to take effect")
+ images: list[str] | None = Field(default=None)
+ contact: ContactDefaults = Field(default_factory=ContactDefaults)
+ republication_interval: int = 7
- @model_validator(mode = "before")
+ @model_validator(mode="before")
@classmethod
- def migrate_legacy_description(cls, values:dict[str, Any]) -> dict[str, Any]:
+ def migrate_legacy_description(cls, values: dict[str, Any]) -> dict[str, Any]:
# Ensure flat prefix/suffix take precedence over deprecated nested "description"
description_prefix = values.get("description_prefix")
description_suffix = values.get("description_suffix")
@@ -114,89 +91,71 @@ class AdDefaults(ContextualModel):
class DownloadConfig(ContextualModel):
- include_all_matching_shipping_options:bool = Field(
- default = False,
- description = "if true, all shipping options matching the package size will be included"
- )
- excluded_shipping_options:list[str] = Field(
- default_factory = list,
- description = "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']"
- )
- folder_name_max_length:int = Field(
- default = 100,
- ge = 10,
- le = 255,
- description = "maximum length for folder names when downloading ads (default: 100)"
- )
- rename_existing_folders:bool = Field(
- default = False,
- description = "if true, rename existing folders without titles to include titles (default: false)"
- )
+ include_all_matching_shipping_options: bool = Field(default=False, description="if true, all shipping options matching the package size will be included")
+ excluded_shipping_options: list[str] = Field(default_factory=list, description="list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']")
+ folder_name_max_length: int = Field(default=100, ge=10, le=255, description="maximum length for folder names when downloading ads (default: 100)")
+ rename_existing_folders: bool = Field(default=False, description="if true, rename existing folders without titles to include titles (default: false)")
class BrowserConfig(ContextualModel):
- arguments:list[str] = Field(
- default_factory = lambda: ["--user-data-dir=.temp/browser-profile"],
- description = "See https://peter.sh/experiments/chromium-command-line-switches/"
+ arguments: list[str] = Field(
+ default_factory=list,
+ description=(
+ "See https://peter.sh/experiments/chromium-command-line-switches/. "
+ "Browser profile path is auto-configured based on installation mode (portable/XDG)."
+ ),
)
- binary_location:str | None = Field(
- default = None,
- description = "path to custom browser executable, if not specified will be looked up on PATH"
+ binary_location: str | None = Field(default=None, description="path to custom browser executable, if not specified will be looked up on PATH")
+ extensions: list[str] = Field(default_factory=list, description="a list of .crx extension files to be loaded")
+ use_private_window: bool = True
+ user_data_dir: str | None = Field(
+ default=None,
+ description=(
+ "See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md. "
+ "If not specified, defaults to XDG cache directory in XDG mode or .temp/browser-profile in portable mode."
+ ),
)
- extensions:list[str] = Field(
- default_factory = list,
- description = "a list of .crx extension files to be loaded"
- )
- use_private_window:bool = True
- user_data_dir:str | None = Field(
- default = ".temp/browser-profile",
- description = "See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md"
- )
- profile_name:str | None = None
+ profile_name: str | None = None
class LoginConfig(ContextualModel):
- username:str = Field(..., min_length = 1)
- password:str = Field(..., min_length = 1)
+ username: str = Field(..., min_length=1)
+ password: str = Field(..., min_length=1)
class PublishingConfig(ContextualModel):
- delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH"
- delete_old_ads_by_title:bool = Field(default = True, description = "only works if delete_old_ads is set to BEFORE_PUBLISH")
+ delete_old_ads: Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH"
+ delete_old_ads_by_title: bool = Field(default=True, description="only works if delete_old_ads is set to BEFORE_PUBLISH")
class CaptchaConfig(ContextualModel):
- auto_restart:bool = False
- restart_delay:str = "6h"
+ auto_restart: bool = False
+ restart_delay: str = "6h"
class TimeoutConfig(ContextualModel):
- multiplier:float = Field(
- default = 1.0,
- ge = 0.1,
- description = "Global multiplier applied to all timeout values."
- )
- default:float = Field(default = 5.0, ge = 0.0, description = "Baseline timeout for DOM interactions.")
- page_load:float = Field(default = 15.0, ge = 1.0, description = "Page load timeout for web_open.")
- captcha_detection:float = Field(default = 2.0, ge = 0.1, description = "Timeout for captcha iframe detection.")
- sms_verification:float = Field(default = 4.0, ge = 0.1, description = "Timeout for SMS verification prompts.")
- gdpr_prompt:float = Field(default = 10.0, ge = 1.0, description = "Timeout for GDPR/consent dialogs.")
- login_detection:float = Field(default = 10.0, ge = 1.0, description = "Timeout for detecting existing login session via DOM elements.")
- publishing_result:float = Field(default = 300.0, ge = 10.0, description = "Timeout for publishing result checks.")
- publishing_confirmation:float = Field(default = 20.0, ge = 1.0, description = "Timeout for publish confirmation redirect.")
- image_upload:float = Field(default = 30.0, ge = 5.0, description = "Timeout for image upload and server-side processing.")
- pagination_initial:float = Field(default = 10.0, ge = 1.0, description = "Timeout for initial pagination lookup.")
- pagination_follow_up:float = Field(default = 5.0, ge = 1.0, description = "Timeout for subsequent pagination navigation.")
- quick_dom:float = Field(default = 2.0, ge = 0.1, description = "Generic short timeout for transient UI.")
- update_check:float = Field(default = 10.0, ge = 1.0, description = "Timeout for GitHub update checks.")
- chrome_remote_probe:float = Field(default = 2.0, ge = 0.1, description = "Timeout for local remote-debugging probes.")
- chrome_remote_debugging:float = Field(default = 5.0, ge = 1.0, description = "Timeout for remote debugging API calls.")
- chrome_binary_detection:float = Field(default = 10.0, ge = 1.0, description = "Timeout for chrome --version subprocesses.")
- retry_enabled:bool = Field(default = True, description = "Enable built-in retry/backoff for DOM operations.")
- retry_max_attempts:int = Field(default = 2, ge = 1, description = "Max retry attempts when retry is enabled.")
- retry_backoff_factor:float = Field(default = 1.5, ge = 1.0, description = "Exponential factor applied per retry attempt.")
+ multiplier: float = Field(default=1.0, ge=0.1, description="Global multiplier applied to all timeout values.")
+ default: float = Field(default=5.0, ge=0.0, description="Baseline timeout for DOM interactions.")
+ page_load: float = Field(default=15.0, ge=1.0, description="Page load timeout for web_open.")
+ captcha_detection: float = Field(default=2.0, ge=0.1, description="Timeout for captcha iframe detection.")
+ sms_verification: float = Field(default=4.0, ge=0.1, description="Timeout for SMS verification prompts.")
+ gdpr_prompt: float = Field(default=10.0, ge=1.0, description="Timeout for GDPR/consent dialogs.")
+ login_detection: float = Field(default=10.0, ge=1.0, description="Timeout for detecting existing login session via DOM elements.")
+ publishing_result: float = Field(default=300.0, ge=10.0, description="Timeout for publishing result checks.")
+ publishing_confirmation: float = Field(default=20.0, ge=1.0, description="Timeout for publish confirmation redirect.")
+ image_upload: float = Field(default=30.0, ge=5.0, description="Timeout for image upload and server-side processing.")
+ pagination_initial: float = Field(default=10.0, ge=1.0, description="Timeout for initial pagination lookup.")
+ pagination_follow_up: float = Field(default=5.0, ge=1.0, description="Timeout for subsequent pagination navigation.")
+ quick_dom: float = Field(default=2.0, ge=0.1, description="Generic short timeout for transient UI.")
+ update_check: float = Field(default=10.0, ge=1.0, description="Timeout for GitHub update checks.")
+ chrome_remote_probe: float = Field(default=2.0, ge=0.1, description="Timeout for local remote-debugging probes.")
+ chrome_remote_debugging: float = Field(default=5.0, ge=1.0, description="Timeout for remote debugging API calls.")
+ chrome_binary_detection: float = Field(default=10.0, ge=1.0, description="Timeout for chrome --version subprocesses.")
+ retry_enabled: bool = Field(default=True, description="Enable built-in retry/backoff for DOM operations.")
+ retry_max_attempts: int = Field(default=2, ge=1, description="Max retry attempts when retry is enabled.")
+ retry_backoff_factor: float = Field(default=1.5, ge=1.0, description="Exponential factor applied per retry attempt.")
- def resolve(self, key:str = "default", override:float | None = None) -> float:
+ def resolve(self, key: str = "default", override: float | None = None) -> float:
"""
Return the base timeout (seconds) for the given key without applying modifiers.
"""
@@ -212,16 +171,16 @@ class TimeoutConfig(ContextualModel):
return float(self.default)
- def effective(self, key:str = "default", override:float | None = None, *, attempt:int = 0) -> float:
+ def effective(self, key: str = "default", override: float | None = None, *, attempt: int = 0) -> float:
"""
Return the effective timeout (seconds) with multiplier/backoff applied.
"""
base = self.resolve(key, override)
- backoff = self.retry_backoff_factor ** attempt if attempt > 0 else 1.0
+ backoff = self.retry_backoff_factor**attempt if attempt > 0 else 1.0
return base * self.multiplier * backoff
-def _validate_glob_pattern(v:str) -> str:
+def _validate_glob_pattern(v: str) -> str:
if not v.strip():
raise ValueError("must be a non-empty, non-blank glob pattern")
return v
@@ -231,21 +190,20 @@ GlobPattern = Annotated[str, AfterValidator(_validate_glob_pattern)]
class Config(ContextualModel):
- ad_files:list[GlobPattern] = Field(
- default_factory = lambda: ["./**/ad_*.{json,yml,yaml}"],
- min_items = 1,
- description = """
+ ad_files: list[GlobPattern] = Field(
+ default_factory=lambda: ["./**/ad_*.{json,yml,yaml}"],
+ min_items=1,
+ description="""
glob (wildcard) patterns to select ad configuration files
if relative paths are specified, then they are relative to this configuration file
-"""
+""",
) # type: ignore[call-overload]
- ad_defaults:AdDefaults = Field(
- default_factory = AdDefaults,
- description = "Default values for ads, can be overwritten in each ad configuration file"
- )
+ ad_defaults: AdDefaults = Field(default_factory=AdDefaults, description="Default values for ads, can be overwritten in each ad configuration file")
- categories:dict[str, str] = Field(default_factory = dict, description = """
+ categories: dict[str, str] = Field(
+ default_factory=dict,
+ description="""
additional name to category ID mappings, see default list at
https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml
@@ -253,17 +211,16 @@ Example:
categories:
Elektronik > Notebooks: 161/278
Jobs > Praktika: 102/125
- """)
+ """,
+ )
- download:DownloadConfig = Field(default_factory = DownloadConfig)
- publishing:PublishingConfig = Field(default_factory = PublishingConfig)
- browser:BrowserConfig = Field(default_factory = BrowserConfig, description = "Browser configuration")
- login:LoginConfig = Field(default_factory = LoginConfig.model_construct, description = "Login credentials")
- captcha:CaptchaConfig = Field(default_factory = CaptchaConfig)
- update_check:UpdateCheckConfig = Field(default_factory = UpdateCheckConfig, description = "Update check configuration")
- timeouts:TimeoutConfig = Field(default_factory = TimeoutConfig, description = "Centralized timeout configuration.")
+ download: DownloadConfig = Field(default_factory=DownloadConfig)
+ publishing: PublishingConfig = Field(default_factory=PublishingConfig)
+ browser: BrowserConfig = Field(default_factory=BrowserConfig, description="Browser configuration")
+ login: LoginConfig = Field(default_factory=LoginConfig.model_construct, description="Login credentials")
+ captcha: CaptchaConfig = Field(default_factory=CaptchaConfig)
+ update_check: UpdateCheckConfig = Field(default_factory=UpdateCheckConfig, description="Update check configuration")
+ timeouts: TimeoutConfig = Field(default_factory=TimeoutConfig, description="Centralized timeout configuration.")
- def with_values(self, values:dict[str, Any]) -> Config:
- return Config.model_validate(
- dicts.apply_defaults(copy.deepcopy(values), defaults = self.model_dump())
- )
+ def with_values(self, values: dict[str, Any]) -> Config:
+ return Config.model_validate(dicts.apply_defaults(copy.deepcopy(values), defaults=self.model_dump()))
diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml
index 10609de..0f0b443 100644
--- a/src/kleinanzeigen_bot/resources/translations.de.yaml
+++ b/src/kleinanzeigen_bot/resources/translations.de.yaml
@@ -112,6 +112,11 @@ kleinanzeigen_bot/__init__.py:
" -> FAILED: Timeout while extending ad '%s': %s": " -> FEHLER: Zeitüberschreitung beim Verlängern der Anzeige '%s': %s"
" -> FAILED: Could not persist extension for ad '%s': %s": " -> FEHLER: Verlängerung der Anzeige '%s' konnte nicht gespeichert werden: %s"
+ finalize_installation_mode:
+ "Config file: %s": "Konfigurationsdatei: %s"
+ "First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus"
+ "Installation mode: %s": "Installationsmodus: %s"
+
publish_ads:
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..."
"Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist"
@@ -240,7 +245,7 @@ kleinanzeigen_bot/__init__.py:
kleinanzeigen_bot/extract.py:
#################################################
download_ad:
- "Ensured ads directory exists at ./%s.": "Verzeichnis [%s] für Anzeige vorhanden."
+ "Using download directory: %s": "Verwende Download-Verzeichnis: %s"
_download_and_save_image_sync:
"Failed to download image %s: %s": "Fehler beim Herunterladen des Bildes %s: %s"
diff --git a/src/kleinanzeigen_bot/update_checker.py b/src/kleinanzeigen_bot/update_checker.py
index e7b7d8b..ad5cebd 100644
--- a/src/kleinanzeigen_bot/update_checker.py
+++ b/src/kleinanzeigen_bot/update_checker.py
@@ -7,7 +7,6 @@ from __future__ import annotations
import logging
from datetime import datetime
from gettext import gettext as _
-from pathlib import Path
from typing import TYPE_CHECKING
import colorama
@@ -22,6 +21,7 @@ except ImportError:
__version__ = "unknown"
from kleinanzeigen_bot.model.update_check_state import UpdateCheckState
+from kleinanzeigen_bot.utils import xdg_paths
logger = logging.getLogger(__name__)
@@ -31,15 +31,16 @@ colorama.init()
class UpdateChecker:
"""Checks for updates to the bot."""
- def __init__(self, config:"Config") -> None:
+ def __init__(self, config: "Config", installation_mode: str | xdg_paths.InstallationMode = "portable") -> None:
"""Initialize the update checker.
Args:
config: The bot configuration.
+ installation_mode: Installation mode (portable/xdg).
"""
self.config = config
- self.state_file = Path(".temp") / "update_check_state.json"
- self.state_file.parent.mkdir(exist_ok = True) # Ensure .temp directory exists
+ self.state_file = xdg_paths.get_update_check_state_path(installation_mode)
+ # Note: xdg_paths handles directory creation
self.state = UpdateCheckState.load(self.state_file)
def get_local_version(self) -> str | None:
@@ -54,7 +55,7 @@ class UpdateChecker:
"""Return the effective timeout for HTTP calls."""
return self.config.timeouts.effective("update_check")
- def _get_commit_hash(self, version:str) -> str | None:
+ def _get_commit_hash(self, version: str) -> str | None:
"""Extract the commit hash from a version string.
Args:
@@ -67,7 +68,7 @@ class UpdateChecker:
return version.split("+")[1]
return None
- def _resolve_commitish(self, commitish:str) -> tuple[str | None, datetime | None]:
+ def _resolve_commitish(self, commitish: str) -> tuple[str | None, datetime | None]:
"""Resolve a commit-ish to a full commit hash and date.
Args:
@@ -79,7 +80,7 @@ class UpdateChecker:
try:
response = requests.get(
f"https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/commits/{commitish}",
- timeout = self._request_timeout()
+ timeout=self._request_timeout(),
)
response.raise_for_status()
data = response.json()
@@ -95,7 +96,7 @@ class UpdateChecker:
logger.warning(_("Could not resolve commit '%s': %s"), commitish, e)
return None, None
- def _get_short_commit_hash(self, commit:str) -> str:
+ def _get_short_commit_hash(self, commit: str) -> str:
"""Get the short version of a commit hash.
Args:
@@ -106,7 +107,7 @@ class UpdateChecker:
"""
return commit[:7]
- def _commits_match(self, local_commit:str, release_commit:str) -> bool:
+ def _commits_match(self, local_commit: str, release_commit: str) -> bool:
"""Determine whether two commits refer to the same hash.
This accounts for short vs. full hashes (e.g. 7 chars vs. 40 chars).
@@ -119,7 +120,7 @@ class UpdateChecker:
return True
return len(release_commit) < len(local_commit) and local_commit.startswith(release_commit)
- def check_for_updates(self, *, skip_interval_check:bool = False) -> None:
+ def check_for_updates(self, *, skip_interval_check: bool = False) -> None:
"""Check for updates to the bot.
Args:
@@ -146,24 +147,16 @@ class UpdateChecker:
try:
if self.config.update_check.channel == "latest":
# Use /releases/latest endpoint for stable releases
- response = requests.get(
- "https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases/latest",
- timeout = self._request_timeout()
- )
+ response = requests.get("https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases/latest", timeout=self._request_timeout())
response.raise_for_status()
release = response.json()
# Defensive: ensure it's not a prerelease
if release.get("prerelease", False):
- logger.warning(
- _("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release.")
- )
+ logger.warning(_("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release."))
return
elif self.config.update_check.channel == "preview":
# Use /releases endpoint and select the most recent prerelease
- response = requests.get(
- "https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases",
- timeout = self._request_timeout()
- )
+ response = requests.get("https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases", timeout=self._request_timeout())
response.raise_for_status()
releases = response.json()
# Find the most recent prerelease
@@ -199,7 +192,7 @@ class UpdateChecker:
_("You are on the latest version: %s (compared to %s in channel %s)"),
local_version,
self._get_short_commit_hash(release_commit),
- self.config.update_check.channel
+ self.config.update_check.channel,
)
self.state.update_last_check()
self.state.save(self.state_file)
@@ -212,7 +205,7 @@ class UpdateChecker:
release_commit_date.strftime("%Y-%m-%d %H:%M:%S"),
local_version,
local_commit_date.strftime("%Y-%m-%d %H:%M:%S"),
- self.config.update_check.channel
+ self.config.update_check.channel,
)
if release.get("body"):
logger.info(_("Release notes:\n%s"), release["body"])
@@ -227,7 +220,7 @@ class UpdateChecker:
self._get_short_commit_hash(local_commit),
local_commit_date.strftime("%Y-%m-%d %H:%M:%S"),
self._get_short_commit_hash(release_commit),
- release_commit_date.strftime("%Y-%m-%d %H:%M:%S")
+ release_commit_date.strftime("%Y-%m-%d %H:%M:%S"),
)
# Update the last check time
diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py
index 3ec4b24..617ff10 100644
--- a/tests/unit/test_extract.py
+++ b/tests/unit/test_extract.py
@@ -17,29 +17,29 @@ from kleinanzeigen_bot.utils.web_scraping_mixin import Browser, By, Element
class _DimensionsDict(TypedDict):
- ad_attributes:str
+ ad_attributes: str
class _UniversalAnalyticsOptsDict(TypedDict):
- dimensions:_DimensionsDict
+ dimensions: _DimensionsDict
class _BelenConfDict(TypedDict):
- universalAnalyticsOpts:_UniversalAnalyticsOptsDict
+ universalAnalyticsOpts: _UniversalAnalyticsOptsDict
-class _SpecialAttributesDict(TypedDict, total = False):
- art_s:str
- condition_s:str
+class _SpecialAttributesDict(TypedDict, total=False):
+ art_s: str
+ condition_s: str
class _TestCaseDict(TypedDict): # noqa: PYI049 Private TypedDict `...` is never used
- belen_conf:_BelenConfDict
- expected:_SpecialAttributesDict
+ belen_conf: _BelenConfDict
+ expected: _SpecialAttributesDict
@pytest.fixture
-def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> AdExtractor:
+def test_extractor(browser_mock: MagicMock, test_bot_config: Config) -> AdExtractor:
"""Provides a fresh AdExtractor instance for testing.
Dependencies:
@@ -52,24 +52,27 @@ def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> AdExtracto
class TestAdExtractorBasics:
"""Basic synchronous tests for AdExtractor."""
- def test_constructor(self, browser_mock:MagicMock, test_bot_config:Config) -> None:
+ def test_constructor(self, browser_mock: MagicMock, test_bot_config: Config) -> None:
"""Test the constructor of AdExtractor"""
extractor = AdExtractor(browser_mock, test_bot_config)
assert extractor.browser == browser_mock
assert extractor.config == test_bot_config
- @pytest.mark.parametrize(("url", "expected_id"), [
- ("https://www.kleinanzeigen.de/s-anzeige/test-title/12345678", 12345678),
- ("https://www.kleinanzeigen.de/s-anzeige/another-test/98765432", 98765432),
- ("https://www.kleinanzeigen.de/s-anzeige/invalid-id/abc", -1),
- ("https://www.kleinanzeigen.de/invalid-url", -1),
- ])
- def test_extract_ad_id_from_ad_url(self, test_extractor:AdExtractor, url:str, expected_id:int) -> None:
+ @pytest.mark.parametrize(
+ ("url", "expected_id"),
+ [
+ ("https://www.kleinanzeigen.de/s-anzeige/test-title/12345678", 12345678),
+ ("https://www.kleinanzeigen.de/s-anzeige/another-test/98765432", 98765432),
+ ("https://www.kleinanzeigen.de/s-anzeige/invalid-id/abc", -1),
+ ("https://www.kleinanzeigen.de/invalid-url", -1),
+ ],
+ )
+ def test_extract_ad_id_from_ad_url(self, test_extractor: AdExtractor, url: str, expected_id: int) -> None:
"""Test extraction of ad ID from different URL formats."""
assert test_extractor.extract_ad_id_from_ad_url(url) == expected_id
@pytest.mark.asyncio
- async def test_path_exists_helper(self, tmp_path:Path) -> None:
+ async def test_path_exists_helper(self, tmp_path: Path) -> None:
"""Test files.exists helper function."""
from kleinanzeigen_bot.utils import files # noqa: PLC0415
@@ -86,7 +89,7 @@ class TestAdExtractorBasics:
assert await files.exists(str(non_existing)) is False
@pytest.mark.asyncio
- async def test_path_is_dir_helper(self, tmp_path:Path) -> None:
+ async def test_path_is_dir_helper(self, tmp_path: Path) -> None:
"""Test files.is_dir helper function."""
from kleinanzeigen_bot.utils import files # noqa: PLC0415
@@ -109,7 +112,7 @@ class TestAdExtractorBasics:
assert await files.is_dir(str(non_existing)) is False
@pytest.mark.asyncio
- async def test_exists_async_helper(self, tmp_path:Path) -> None:
+ async def test_exists_async_helper(self, tmp_path: Path) -> None:
"""Test files.exists async helper function."""
from kleinanzeigen_bot.utils import files # noqa: PLC0415
@@ -125,7 +128,7 @@ class TestAdExtractorBasics:
assert await files.exists(str(non_existing)) is False
@pytest.mark.asyncio
- async def test_isdir_async_helper(self, tmp_path:Path) -> None:
+ async def test_isdir_async_helper(self, tmp_path: Path) -> None:
"""Test files.is_dir async helper function."""
from kleinanzeigen_bot.utils import files # noqa: PLC0415
@@ -146,7 +149,7 @@ class TestAdExtractorBasics:
assert await files.is_dir(non_existing) is False
assert await files.is_dir(str(non_existing)) is False
- def test_download_and_save_image_sync_success(self, tmp_path:Path) -> None:
+ def test_download_and_save_image_sync_success(self, tmp_path: Path) -> None:
"""Test _download_and_save_image_sync with successful download."""
from unittest.mock import MagicMock, mock_open # noqa: PLC0415
@@ -156,33 +159,24 @@ class TestAdExtractorBasics:
# Mock urllib response
mock_response = MagicMock()
mock_response.info().get_content_type.return_value = "image/jpeg"
- mock_response.__enter__ = MagicMock(return_value = mock_response)
- mock_response.__exit__ = MagicMock(return_value = False)
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
+ mock_response.__exit__ = MagicMock(return_value=False)
- with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", return_value = mock_response), \
- patch("kleinanzeigen_bot.extract.open", mock_open()), \
- patch("kleinanzeigen_bot.extract.shutil.copyfileobj"):
-
- result = AdExtractor._download_and_save_image_sync(
- "http://example.com/image.jpg",
- str(test_dir),
- "test_",
- 1
- )
+ with (
+ patch("kleinanzeigen_bot.extract.urllib_request.urlopen", return_value=mock_response),
+ patch("kleinanzeigen_bot.extract.open", mock_open()),
+ patch("kleinanzeigen_bot.extract.shutil.copyfileobj"),
+ ):
+ result = AdExtractor._download_and_save_image_sync("http://example.com/image.jpg", str(test_dir), "test_", 1)
assert result is not None
assert result.endswith((".jpe", ".jpeg", ".jpg"))
assert "test_1" in result
- def test_download_and_save_image_sync_failure(self, tmp_path:Path) -> None:
+ def test_download_and_save_image_sync_failure(self, tmp_path: Path) -> None:
"""Test _download_and_save_image_sync with download failure."""
- with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", side_effect = URLError("Network error")):
- result = AdExtractor._download_and_save_image_sync(
- "http://example.com/image.jpg",
- str(tmp_path),
- "test_",
- 1
- )
+ with patch("kleinanzeigen_bot.extract.urllib_request.urlopen", side_effect=URLError("Network error")):
+ result = AdExtractor._download_and_save_image_sync("http://example.com/image.jpg", str(tmp_path), "test_", 1)
assert result is None
@@ -190,29 +184,30 @@ class TestAdExtractorBasics:
class TestAdExtractorPricing:
"""Tests for pricing related functionality."""
- @pytest.mark.parametrize(("price_text", "expected_price", "expected_type"), [
- ("50 €", 50, "FIXED"),
- ("1.234 €", 1234, "FIXED"),
- ("50 € VB", 50, "NEGOTIABLE"),
- ("VB", None, "NEGOTIABLE"),
- ("Zu verschenken", None, "GIVE_AWAY"),
- ])
+ @pytest.mark.parametrize(
+ ("price_text", "expected_price", "expected_type"),
+ [
+ ("50 €", 50, "FIXED"),
+ ("1.234 €", 1234, "FIXED"),
+ ("50 € VB", 50, "NEGOTIABLE"),
+ ("VB", None, "NEGOTIABLE"),
+ ("Zu verschenken", None, "GIVE_AWAY"),
+ ],
+ )
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_pricing_info(
- self, test_extractor:AdExtractor, price_text:str, expected_price:int | None, expected_type:str
- ) -> None:
+ async def test_extract_pricing_info(self, test_extractor: AdExtractor, price_text: str, expected_price: int | None, expected_type: str) -> None:
"""Test price extraction with different formats"""
- with patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = price_text):
+ with patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value=price_text):
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
assert price == expected_price
assert price_type == expected_type
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_pricing_info_timeout(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_pricing_info_timeout(self, test_extractor: AdExtractor) -> None:
"""Test price extraction when element is not found"""
- with patch.object(test_extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError):
+ with patch.object(test_extractor, "web_text", new_callable=AsyncMock, side_effect=TimeoutError):
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
assert price is None
assert price_type == "NOT_APPLICABLE"
@@ -221,30 +216,26 @@ class TestAdExtractorPricing:
class TestAdExtractorShipping:
"""Tests for shipping related functionality."""
- @pytest.mark.parametrize(("shipping_text", "expected_type", "expected_cost"), [
- ("+ Versand ab 2,99 €", "SHIPPING", 2.99),
- ("Nur Abholung", "PICKUP", None),
- ("Versand möglich", "SHIPPING", None),
- ])
+ @pytest.mark.parametrize(
+ ("shipping_text", "expected_type", "expected_cost"),
+ [
+ ("+ Versand ab 2,99 €", "SHIPPING", 2.99),
+ ("Nur Abholung", "PICKUP", None),
+ ("Versand möglich", "SHIPPING", None),
+ ],
+ )
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_shipping_info(
- self, test_extractor:AdExtractor, shipping_text:str, expected_type:str, expected_cost:float | None
- ) -> None:
+ async def test_extract_shipping_info(self, test_extractor: AdExtractor, shipping_text: str, expected_type: str, expected_cost: float | None) -> None:
"""Test shipping info extraction with different text formats."""
- with patch.object(test_extractor, "page", MagicMock()), \
- patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = shipping_text), \
- patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
-
+ with (
+ patch.object(test_extractor, "page", MagicMock()),
+ patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value=shipping_text),
+ patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request,
+ ):
if expected_cost:
- shipping_response:dict[str, Any] = {
- "data": {
- "shippingOptionsResponse": {
- "options": [
- {"id": "DHL_001", "priceInEuroCent": int(expected_cost * 100), "packageSize": "SMALL"}
- ]
- }
- }
+ shipping_response: dict[str, Any] = {
+ "data": {"shippingOptionsResponse": {"options": [{"id": "DHL_001", "priceInEuroCent": int(expected_cost * 100), "packageSize": "SMALL"}]}}
}
mock_web_request.return_value = {"content": json.dumps(shipping_response)}
@@ -259,24 +250,17 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_shipping_info_with_options(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_shipping_info_with_options(self, test_extractor: AdExtractor) -> None:
"""Test shipping info extraction with shipping options."""
shipping_response = {
- "content": json.dumps({
- "data": {
- "shippingOptionsResponse": {
- "options": [
- {"id": "DHL_001", "priceInEuroCent": 549, "packageSize": "SMALL"}
- ]
- }
- }
- })
+ "content": json.dumps({"data": {"shippingOptionsResponse": {"options": [{"id": "DHL_001", "priceInEuroCent": 549, "packageSize": "SMALL"}]}}})
}
- with patch.object(test_extractor, "page", MagicMock()), \
- patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 5,49 €"), \
- patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
-
+ with (
+ patch.object(test_extractor, "page", MagicMock()),
+ patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 5,49 €"),
+ patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response),
+ ):
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == "SHIPPING"
@@ -285,29 +269,32 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_shipping_info_with_all_matching_options(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_shipping_info_with_all_matching_options(self, test_extractor: AdExtractor) -> None:
"""Test shipping info extraction with all matching options enabled."""
shipping_response = {
- "content": json.dumps({
- "data": {
- "shippingOptionsResponse": {
- "options": [
- {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
- {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
- {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}
- ]
+ "content": json.dumps(
+ {
+ "data": {
+ "shippingOptionsResponse": {
+ "options": [
+ {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
+ {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
+ {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"},
+ ]
+ }
}
}
- })
+ )
}
# Enable all matching options in config
test_extractor.config.download = DownloadConfig.model_validate({"include_all_matching_shipping_options": True})
- with patch.object(test_extractor, "page", MagicMock()), \
- patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
- patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
-
+ with (
+ patch.object(test_extractor, "page", MagicMock()),
+ patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"),
+ patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response),
+ ):
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == "SHIPPING"
@@ -319,32 +306,32 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_shipping_info_with_excluded_options(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_shipping_info_with_excluded_options(self, test_extractor: AdExtractor) -> None:
"""Test shipping info extraction with excluded options."""
shipping_response = {
- "content": json.dumps({
- "data": {
- "shippingOptionsResponse": {
- "options": [
- {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
- {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
- {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}
- ]
+ "content": json.dumps(
+ {
+ "data": {
+ "shippingOptionsResponse": {
+ "options": [
+ {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
+ {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
+ {"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"},
+ ]
+ }
}
}
- })
+ )
}
# Enable all matching options and exclude DHL in config
- test_extractor.config.download = DownloadConfig.model_validate({
- "include_all_matching_shipping_options": True,
- "excluded_shipping_options": ["DHL_2"]
- })
-
- with patch.object(test_extractor, "page", MagicMock()), \
- patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
- patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
+ test_extractor.config.download = DownloadConfig.model_validate({"include_all_matching_shipping_options": True, "excluded_shipping_options": ["DHL_2"]})
+ with (
+ patch.object(test_extractor, "page", MagicMock()),
+ patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"),
+ patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response),
+ ):
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == "SHIPPING"
@@ -356,30 +343,31 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_shipping_info_with_excluded_matching_option(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_shipping_info_with_excluded_matching_option(self, test_extractor: AdExtractor) -> None:
"""Test shipping info extraction when the matching option is excluded."""
shipping_response = {
- "content": json.dumps({
- "data": {
- "shippingOptionsResponse": {
- "options": [
- {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
- {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}
- ]
+ "content": json.dumps(
+ {
+ "data": {
+ "shippingOptionsResponse": {
+ "options": [
+ {"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
+ {"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
+ ]
+ }
}
}
- })
+ )
}
# Exclude the matching option
- test_extractor.config.download = DownloadConfig.model_validate({
- "excluded_shipping_options": ["Hermes_Päckchen"]
- })
-
- with patch.object(test_extractor, "page", MagicMock()), \
- patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
- patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
+ test_extractor.config.download = DownloadConfig.model_validate({"excluded_shipping_options": ["Hermes_Päckchen"]})
+ with (
+ patch.object(test_extractor, "page", MagicMock()),
+ patch.object(test_extractor, "web_text", new_callable=AsyncMock, return_value="+ Versand ab 4,89 €"),
+ patch.object(test_extractor, "web_request", new_callable=AsyncMock, return_value=shipping_response),
+ ):
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == "NOT_APPLICABLE"
@@ -391,21 +379,22 @@ class TestAdExtractorNavigation:
"""Tests for navigation related functionality."""
@pytest.mark.asyncio
- async def test_navigate_to_ad_page_with_url(self, test_extractor:AdExtractor) -> None:
+ async def test_navigate_to_ad_page_with_url(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page using a URL."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
- with patch.object(test_extractor, "page", page_mock), \
- patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError):
-
+ with (
+ patch.object(test_extractor, "page", page_mock),
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock) as mock_web_open,
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError),
+ ):
result = await test_extractor.navigate_to_ad_page("https://www.kleinanzeigen.de/s-anzeige/test/12345")
assert result is True
mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-anzeige/test/12345")
@pytest.mark.asyncio
- async def test_navigate_to_ad_page_with_id(self, test_extractor:AdExtractor) -> None:
+ async def test_navigate_to_ad_page_with_id(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page using an ID."""
ad_id = 12345
page_mock = AsyncMock()
@@ -413,24 +402,25 @@ class TestAdExtractorNavigation:
popup_close_mock = AsyncMock()
popup_close_mock.click = AsyncMock()
- popup_close_mock.apply = AsyncMock(return_value = True)
+ popup_close_mock.apply = AsyncMock(return_value=True)
- def find_mock(selector_type:By, selector_value:str, **_:Any) -> Element | None:
+ def find_mock(selector_type: By, selector_value: str, **_: Any) -> Element | None:
if selector_type == By.CLASS_NAME and selector_value == "mfp-close":
return popup_close_mock
return None
- with patch.object(test_extractor, "page", page_mock), \
- patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = find_mock):
-
+ with (
+ patch.object(test_extractor, "page", page_mock),
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock) as mock_web_open,
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=find_mock),
+ ):
result = await test_extractor.navigate_to_ad_page(ad_id)
assert result is True
mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-suchanfrage.html?keywords={0}".format(ad_id))
popup_close_mock.click.assert_awaited_once()
@pytest.mark.asyncio
- async def test_navigate_to_ad_page_with_popup(self, test_extractor:AdExtractor) -> None:
+ async def test_navigate_to_ad_page_with_popup(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page with popup handling."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
@@ -438,20 +428,21 @@ class TestAdExtractorNavigation:
input_mock = AsyncMock()
input_mock.clear_input = AsyncMock()
input_mock.send_keys = AsyncMock()
- input_mock.apply = AsyncMock(return_value = True)
-
- with patch.object(test_extractor, "page", page_mock), \
- patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock), \
- patch.object(test_extractor, "web_click", new_callable = AsyncMock) as mock_web_click, \
- patch.object(test_extractor, "web_check", new_callable = AsyncMock, return_value = True):
+ input_mock.apply = AsyncMock(return_value=True)
+ with (
+ patch.object(test_extractor, "page", page_mock),
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock, return_value=input_mock),
+ patch.object(test_extractor, "web_click", new_callable=AsyncMock) as mock_web_click,
+ patch.object(test_extractor, "web_check", new_callable=AsyncMock, return_value=True),
+ ):
result = await test_extractor.navigate_to_ad_page(12345)
assert result is True
mock_web_click.assert_called_with(By.CLASS_NAME, "mfp-close")
@pytest.mark.asyncio
- async def test_navigate_to_ad_page_invalid_id(self, test_extractor:AdExtractor) -> None:
+ async def test_navigate_to_ad_page_invalid_id(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page with invalid ID."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-suchen.html?k0"
@@ -459,26 +450,28 @@ class TestAdExtractorNavigation:
input_mock = AsyncMock()
input_mock.clear_input = AsyncMock()
input_mock.send_keys = AsyncMock()
- input_mock.apply = AsyncMock(return_value = True)
+ input_mock.apply = AsyncMock(return_value=True)
input_mock.attrs = {}
- with patch.object(test_extractor, "page", page_mock), \
- patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock):
-
+ with (
+ patch.object(test_extractor, "page", page_mock),
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock, return_value=input_mock),
+ ):
result = await test_extractor.navigate_to_ad_page(99999)
assert result is False
@pytest.mark.asyncio
- async def test_extract_own_ads_urls(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_own_ads_urls(self, test_extractor: AdExtractor) -> None:
"""Test extraction of own ads URLs - basic test."""
- with patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_sleep", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock) as mock_web_find, \
- patch.object(test_extractor, "web_find_all", new_callable = AsyncMock) as mock_web_find_all, \
- patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_execute", new_callable = AsyncMock):
-
+ with (
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_sleep", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock) as mock_web_find,
+ patch.object(test_extractor, "web_find_all", new_callable=AsyncMock) as mock_web_find_all,
+ patch.object(test_extractor, "web_scroll_page_down", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_execute", new_callable=AsyncMock),
+ ):
# --- Setup mock objects for DOM elements ---
# Mocks needed for the actual execution flow
ad_list_container_mock = MagicMock()
@@ -498,18 +491,18 @@ class TestAdExtractorNavigation:
# 3. Find for ad list container (inside loop)
# 4. Find for the link (inside list comprehension)
mock_web_find.side_effect = [
- ad_list_container_mock, # Call 1: find #my-manageitems-adlist (before loop)
+ ad_list_container_mock, # Call 1: find #my-manageitems-adlist (before loop)
pagination_section_mock, # Call 2: find .Pagination
- ad_list_container_mock, # Call 3: find #my-manageitems-adlist (inside loop)
- link_mock # Call 4: find 'div.manageitems-item-ad h3 a.text-onSurface'
+ ad_list_container_mock, # Call 3: find #my-manageitems-adlist (inside loop)
+ link_mock, # Call 4: find 'div.manageitems-item-ad h3 a.text-onSurface'
# Add more mocks here if the pagination navigation logic calls web_find again
]
# 1. Find all 'Nächste' buttons (pagination check) - Return empty list for single page test case
# 2. Find all '.cardbox' elements (inside loop)
mock_web_find_all.side_effect = [
- [], # Call 1: find 'button[aria-label="Nächste"]' -> No next button = single page
- [cardbox_mock] # Call 2: find .cardbox -> One ad item
+ [], # Call 1: find 'button[aria-label="Nächste"]' -> No next button = single page
+ [cardbox_mock], # Call 2: find .cardbox -> One ad item
# Add more mocks here if pagination navigation calls web_find_all
]
@@ -520,27 +513,33 @@ class TestAdExtractorNavigation:
assert refs == ["/s-anzeige/test/12345"] # Now it should match
# Optional: Verify calls were made as expected
- mock_web_find.assert_has_calls([
- call(By.ID, "my-manageitems-adlist"),
- call(By.CSS_SELECTOR, ".Pagination", timeout = 10),
- call(By.ID, "my-manageitems-adlist"),
- call(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = cardbox_mock),
- ], any_order = False) # Check order if important
+ mock_web_find.assert_has_calls(
+ [
+ call(By.ID, "my-manageitems-adlist"),
+ call(By.CSS_SELECTOR, ".Pagination", timeout=10),
+ call(By.ID, "my-manageitems-adlist"),
+ call(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent=cardbox_mock),
+ ],
+ any_order=False,
+ ) # Check order if important
- mock_web_find_all.assert_has_calls([
- call(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section_mock),
- call(By.CLASS_NAME, "cardbox", parent = ad_list_container_mock),
- ], any_order = False)
+ mock_web_find_all.assert_has_calls(
+ [
+ call(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent=pagination_section_mock),
+ call(By.CLASS_NAME, "cardbox", parent=ad_list_container_mock),
+ ],
+ any_order=False,
+ )
@pytest.mark.asyncio
- async def test_extract_own_ads_urls_paginates_with_enabled_next_button(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_own_ads_urls_paginates_with_enabled_next_button(self, test_extractor: AdExtractor) -> None:
"""Ensure the paginator clicks the first enabled next button and advances."""
ad_list_container_mock = MagicMock()
pagination_section_mock = MagicMock()
cardbox_page_one = MagicMock()
cardbox_page_two = MagicMock()
- link_page_one = MagicMock(attrs = {"href": "/s-anzeige/page-one/111"})
- link_page_two = MagicMock(attrs = {"href": "/s-anzeige/page-two/222"})
+ link_page_one = MagicMock(attrs={"href": "/s-anzeige/page-one/111"})
+ link_page_two = MagicMock(attrs={"href": "/s-anzeige/page-two/222"})
next_button_enabled = AsyncMock()
next_button_enabled.attrs = {}
@@ -551,8 +550,7 @@ class TestAdExtractorNavigation:
next_button_call = {"count": 0}
cardbox_call = {"count": 0}
- async def fake_web_find(selector_type:By, selector_value:str, *, parent:Element | None = None,
- timeout:int | float | None = None) -> Element:
+ async def fake_web_find(selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None) -> Element:
if selector_type == By.ID and selector_value == "my-manageitems-adlist":
return ad_list_container_mock
if selector_type == By.CSS_SELECTOR and selector_value == ".Pagination":
@@ -561,8 +559,9 @@ class TestAdExtractorNavigation:
return link_queue.pop(0)
raise AssertionError(f"Unexpected selector {selector_type} {selector_value}")
- async def fake_web_find_all(selector_type:By, selector_value:str, *, parent:Element | None = None,
- timeout:int | float | None = None) -> list[Element]:
+ async def fake_web_find_all(
+ selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None
+ ) -> list[Element]:
if selector_type == By.CSS_SELECTOR and selector_value == 'button[aria-label="Nächste"]':
next_button_call["count"] += 1
if next_button_call["count"] == 1:
@@ -575,12 +574,13 @@ class TestAdExtractorNavigation:
return [cardbox_page_one] if cardbox_call["count"] == 1 else [cardbox_page_two]
raise AssertionError(f"Unexpected find_all selector {selector_type} {selector_value}")
- with patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_sleep", new_callable = AsyncMock), \
- patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = fake_web_find), \
- patch.object(test_extractor, "web_find_all", new_callable = AsyncMock, side_effect = fake_web_find_all):
-
+ with (
+ patch.object(test_extractor, "web_open", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_scroll_page_down", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_sleep", new_callable=AsyncMock),
+ patch.object(test_extractor, "web_find", new_callable=AsyncMock, side_effect=fake_web_find),
+ patch.object(test_extractor, "web_find_all", new_callable=AsyncMock, side_effect=fake_web_find_all),
+ ):
refs = await test_extractor.extract_own_ads_urls()
assert refs == ["/s-anzeige/page-one/111", "/s-anzeige/page-two/222"]
@@ -589,20 +589,18 @@ class TestAdExtractorNavigation:
class TestAdExtractorContent:
"""Tests for content extraction functionality."""
+
# pylint: disable=protected-access
@pytest.fixture
def extractor_with_config(self) -> AdExtractor:
"""Create extractor with specific config for testing prefix/suffix handling."""
- browser_mock = MagicMock(spec = Browser)
+ browser_mock = MagicMock(spec=Browser)
return AdExtractor(browser_mock, Config()) # Empty config, will be overridden in tests
@pytest.mark.asyncio
async def test_extract_description_with_affixes(
- self,
- test_extractor:AdExtractor,
- description_test_cases:list[tuple[dict[str, Any], str, str]],
- test_bot_config:Config
+ self, test_extractor: AdExtractor, description_test_cases: list[tuple[dict[str, Any], str, str]], test_bot_config: Config
) -> None:
"""Test extraction of description with various prefix/suffix configurations."""
# Mock the page
@@ -613,63 +611,52 @@ class TestAdExtractorContent:
for config, raw_description, _expected_description in description_test_cases:
test_extractor.config = test_bot_config.with_values(config)
- with patch.multiple(test_extractor,
- web_text = AsyncMock(side_effect = [
- "Test Title", # Title
- raw_description, # Raw description (without affixes)
- "03.02.2025" # Creation date
- ]),
- web_execute = AsyncMock(return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- _extract_category_from_ad_page = AsyncMock(return_value = "160"),
- _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
- _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
- _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
- _extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
- _download_images_from_ad_page = AsyncMock(return_value = []),
- _extract_contact_from_ad_page = AsyncMock(return_value = {})
+ with patch.multiple(
+ test_extractor,
+ web_text=AsyncMock(
+ side_effect=[
+ "Test Title", # Title
+ raw_description, # Raw description (without affixes)
+ "03.02.2025", # Creation date
+ ]
+ ),
+ web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}),
+ _extract_category_from_ad_page=AsyncMock(return_value="160"),
+ _extract_special_attributes_from_ad_page=AsyncMock(return_value={}),
+ _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")),
+ _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)),
+ _extract_sell_directly_from_ad_page=AsyncMock(return_value=False),
+ _download_images_from_ad_page=AsyncMock(return_value=[]),
+ _extract_contact_from_ad_page=AsyncMock(return_value={}),
):
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
assert info.description == raw_description
@pytest.mark.asyncio
- async def test_extract_description_with_affixes_timeout(
- self,
- test_extractor:AdExtractor
- ) -> None:
+ async def test_extract_description_with_affixes_timeout(self, test_extractor: AdExtractor) -> None:
"""Test handling of timeout when extracting description."""
# Mock the page
page_mock = MagicMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
test_extractor.page = page_mock
- with patch.multiple(test_extractor,
- web_text = AsyncMock(side_effect = [
- "Test Title", # Title succeeds
- TimeoutError("Timeout"), # Description times out
- "03.02.2025" # Date succeeds
- ]),
- web_execute = AsyncMock(return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- _extract_category_from_ad_page = AsyncMock(return_value = "160"),
- _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
- _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
- _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
- _extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
- _download_images_from_ad_page = AsyncMock(return_value = []),
- _extract_contact_from_ad_page = AsyncMock(return_value = ContactPartial())
+ with patch.multiple(
+ test_extractor,
+ web_text=AsyncMock(
+ side_effect=[
+ "Test Title", # Title succeeds
+ TimeoutError("Timeout"), # Description times out
+ "03.02.2025", # Date succeeds
+ ]
+ ),
+ web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}),
+ _extract_category_from_ad_page=AsyncMock(return_value="160"),
+ _extract_special_attributes_from_ad_page=AsyncMock(return_value={}),
+ _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")),
+ _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)),
+ _extract_sell_directly_from_ad_page=AsyncMock(return_value=False),
+ _download_images_from_ad_page=AsyncMock(return_value=[]),
+ _extract_contact_from_ad_page=AsyncMock(return_value=ContactPartial()),
):
try:
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
@@ -679,10 +666,7 @@ class TestAdExtractorContent:
pass
@pytest.mark.asyncio
- async def test_extract_description_with_affixes_no_affixes(
- self,
- test_extractor:AdExtractor
- ) -> None:
+ async def test_extract_description_with_affixes_no_affixes(self, test_extractor: AdExtractor) -> None:
"""Test extraction of description without any affixes in config."""
# Mock the page
page_mock = MagicMock()
@@ -690,33 +674,29 @@ class TestAdExtractorContent:
test_extractor.page = page_mock
raw_description = "Original Description"
- with patch.multiple(test_extractor,
- web_text = AsyncMock(side_effect = [
- "Test Title", # Title
- raw_description, # Description without affixes
- "03.02.2025" # Creation date
- ]),
- web_execute = AsyncMock(return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- _extract_category_from_ad_page = AsyncMock(return_value = "160"),
- _extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
- _extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
- _extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
- _extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
- _download_images_from_ad_page = AsyncMock(return_value = []),
- _extract_contact_from_ad_page = AsyncMock(return_value = ContactPartial())
+ with patch.multiple(
+ test_extractor,
+ web_text=AsyncMock(
+ side_effect=[
+ "Test Title", # Title
+ raw_description, # Description without affixes
+ "03.02.2025", # Creation date
+ ]
+ ),
+ web_execute=AsyncMock(return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}}),
+ _extract_category_from_ad_page=AsyncMock(return_value="160"),
+ _extract_special_attributes_from_ad_page=AsyncMock(return_value={}),
+ _extract_pricing_info_from_ad_page=AsyncMock(return_value=(None, "NOT_APPLICABLE")),
+ _extract_shipping_info_from_ad_page=AsyncMock(return_value=("NOT_APPLICABLE", None, None)),
+ _extract_sell_directly_from_ad_page=AsyncMock(return_value=False),
+ _download_images_from_ad_page=AsyncMock(return_value=[]),
+ _extract_contact_from_ad_page=AsyncMock(return_value=ContactPartial()),
):
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
assert info.description == raw_description
@pytest.mark.asyncio
- async def test_extract_sell_directly(self, test_extractor:AdExtractor) -> None:
+ async def test_extract_sell_directly(self, test_extractor: AdExtractor) -> None:
"""Test extraction of sell directly option."""
# Mock the page URL to extract the ad ID
test_extractor.page = MagicMock()
@@ -724,7 +704,7 @@ class TestAdExtractorContent:
# Test when extract_ad_id_from_ad_url returns -1 (invalid URL)
test_extractor.page.url = "https://www.kleinanzeigen.de/invalid-url"
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -735,14 +715,9 @@ class TestAdExtractorContent:
test_extractor.page.url = "https://www.kleinanzeigen.de/s-anzeige/test-ad/123456789"
# Test successful extraction with buyNowEligible = true
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 123456789, "buyNowEligible": True},
- {"id": 987654321, "buyNowEligible": False}
- ]
- })
+ "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": True}, {"id": 987654321, "buyNowEligible": False}]})
}
result = await test_extractor._extract_sell_directly_from_ad_page()
@@ -752,14 +727,9 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test successful extraction with buyNowEligible = false
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 123456789, "buyNowEligible": False},
- {"id": 987654321, "buyNowEligible": True}
- ]
- })
+ "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": False}, {"id": 987654321, "buyNowEligible": True}]})
}
result = await test_extractor._extract_sell_directly_from_ad_page()
@@ -769,14 +739,16 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when buyNowEligible is missing from the current ad
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 123456789}, # No buyNowEligible field
- {"id": 987654321, "buyNowEligible": True}
- ]
- })
+ "content": json.dumps(
+ {
+ "ads": [
+ {"id": 123456789}, # No buyNowEligible field
+ {"id": 987654321, "buyNowEligible": True},
+ ]
+ }
+ )
}
result = await test_extractor._extract_sell_directly_from_ad_page()
@@ -786,14 +758,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when current ad is not found in the ads list
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 987654321, "buyNowEligible": True}
- ]
- })
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": json.dumps({"ads": [{"id": 987654321, "buyNowEligible": True}]})}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -802,7 +768,7 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test timeout error
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock, side_effect = TimeoutError) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock, side_effect=TimeoutError) as mock_web_request:
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -810,10 +776,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test JSON decode error
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": "invalid json"
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": "invalid json"}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -822,10 +786,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when ads list is empty
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": json.dumps({"ads": []})
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": json.dumps({"ads": []})}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -834,14 +796,9 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when buyNowEligible is a non-boolean value (string "true")
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 123456789, "buyNowEligible": "true"},
- {"id": 987654321, "buyNowEligible": False}
- ]
- })
+ "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": "true"}, {"id": 987654321, "buyNowEligible": False}]})
}
result = await test_extractor._extract_sell_directly_from_ad_page()
@@ -851,14 +808,9 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when buyNowEligible is a non-boolean value (integer 1)
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
mock_web_request.return_value = {
- "content": json.dumps({
- "ads": [
- {"id": 123456789, "buyNowEligible": 1},
- {"id": 987654321, "buyNowEligible": False}
- ]
- })
+ "content": json.dumps({"ads": [{"id": 123456789, "buyNowEligible": 1}, {"id": 987654321, "buyNowEligible": False}]})
}
result = await test_extractor._extract_sell_directly_from_ad_page()
@@ -868,10 +820,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when json_data is not a dict (covers line 622)
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": json.dumps(["not", "a", "dict"])
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": json.dumps(["not", "a", "dict"])}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -880,10 +830,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when json_data is a dict but doesn't have "ads" key (covers line 622)
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": json.dumps({"other_key": "value"})
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": json.dumps({"other_key": "value"})}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -892,10 +840,8 @@ class TestAdExtractorContent:
mock_web_request.assert_awaited_once_with("https://www.kleinanzeigen.de/m-meine-anzeigen-verwalten.json")
# Test when ads_list is not a list (covers line 624)
- with patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
- mock_web_request.return_value = {
- "content": json.dumps({"ads": "not a list"})
- }
+ with patch.object(test_extractor, "web_request", new_callable=AsyncMock) as mock_web_request:
+ mock_web_request.return_value = {"content": json.dumps({"ads": "not a list"})}
result = await test_extractor._extract_sell_directly_from_ad_page()
assert result is None
@@ -908,21 +854,14 @@ class TestAdExtractorCategory:
"""Tests for category extraction functionality."""
@pytest.fixture
- def extractor(self, test_bot_config:Config) -> AdExtractor:
- browser_mock = MagicMock(spec = Browser)
- config = test_bot_config.with_values({
- "ad_defaults": {
- "description": {
- "prefix": "Test Prefix",
- "suffix": "Test Suffix"
- }
- }
- })
+ def extractor(self, test_bot_config: Config) -> AdExtractor:
+ browser_mock = MagicMock(spec=Browser)
+ config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
return AdExtractor(browser_mock, config)
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_category(self, extractor:AdExtractor) -> None:
+ async def test_extract_category(self, extractor: AdExtractor) -> None:
"""Test category extraction from breadcrumb."""
category_line = MagicMock()
first_part = MagicMock()
@@ -930,35 +869,37 @@ class TestAdExtractorCategory:
second_part = MagicMock()
second_part.attrs = {"href": "/s-spielzeug/c23"}
- with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = [category_line]) as mock_web_find, \
- patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [first_part, second_part]) as mock_web_find_all:
-
+ with (
+ patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=[category_line]) as mock_web_find,
+ patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[first_part, second_part]) as mock_web_find_all,
+ ):
result = await extractor._extract_category_from_ad_page()
assert result == "17/23"
mock_web_find.assert_awaited_once_with(By.ID, "vap-brdcrmb")
- mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line)
+ mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line)
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_category_single_identifier(self, extractor:AdExtractor) -> None:
+ async def test_extract_category_single_identifier(self, extractor: AdExtractor) -> None:
"""Test category extraction when only a single breadcrumb code exists."""
category_line = MagicMock()
first_part = MagicMock()
first_part.attrs = {"href": "/s-kleidung/c42"}
- with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = [category_line]) as mock_web_find, \
- patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [first_part]) as mock_web_find_all:
-
+ with (
+ patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=[category_line]) as mock_web_find,
+ patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[first_part]) as mock_web_find_all,
+ ):
result = await extractor._extract_category_from_ad_page()
assert result == "42/42"
mock_web_find.assert_awaited_once_with(By.ID, "vap-brdcrmb")
- mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line)
+ mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line)
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_category_fallback_to_legacy_selectors(self, extractor:AdExtractor, caplog:pytest.LogCaptureFixture) -> None:
+ async def test_extract_category_fallback_to_legacy_selectors(self, extractor: AdExtractor, caplog: pytest.LogCaptureFixture) -> None:
"""Test category extraction when breadcrumb links are not available and legacy selectors are used."""
category_line = MagicMock()
first_part = MagicMock()
@@ -968,67 +909,58 @@ class TestAdExtractorCategory:
caplog.set_level("DEBUG")
expected_message = _("Falling back to legacy breadcrumb selectors; collected ids: %s") % []
- with patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find, \
- patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError) as mock_web_find_all:
-
- mock_web_find.side_effect = [
- category_line,
- first_part,
- second_part
- ]
+ with (
+ patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find,
+ patch.object(extractor, "web_find_all", new_callable=AsyncMock, side_effect=TimeoutError) as mock_web_find_all,
+ ):
+ mock_web_find.side_effect = [category_line, first_part, second_part]
result = await extractor._extract_category_from_ad_page()
assert result == "12345/67890"
assert sum(1 for record in caplog.records if record.message == expected_message) == 1
mock_web_find.assert_any_call(By.ID, "vap-brdcrmb")
- mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
- mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line)
- mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line)
+ mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent=category_line)
+ mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(3)", parent=category_line)
+ mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent=category_line)
@pytest.mark.asyncio
- async def test_extract_category_legacy_selectors_timeout(self, extractor:AdExtractor, caplog:pytest.LogCaptureFixture) -> None:
+ async def test_extract_category_legacy_selectors_timeout(self, extractor: AdExtractor, caplog: pytest.LogCaptureFixture) -> None:
"""Ensure fallback timeout logs the error and re-raises with translated message."""
category_line = MagicMock()
- async def fake_web_find(selector_type:By, selector_value:str, *, parent:Element | None = None,
- timeout:int | float | None = None) -> Element:
+ async def fake_web_find(selector_type: By, selector_value: str, *, parent: Element | None = None, timeout: int | float | None = None) -> Element:
if selector_type == By.ID and selector_value == "vap-brdcrmb":
return category_line
raise TimeoutError("legacy selectors missing")
- with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = fake_web_find), \
- patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError), \
- caplog.at_level("ERROR"), pytest.raises(TimeoutError, match = "Unable to locate breadcrumb fallback selectors"):
+ with (
+ patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=fake_web_find),
+ patch.object(extractor, "web_find_all", new_callable=AsyncMock, side_effect=TimeoutError),
+ caplog.at_level("ERROR"),
+ pytest.raises(TimeoutError, match="Unable to locate breadcrumb fallback selectors"),
+ ):
await extractor._extract_category_from_ad_page()
assert any("Legacy breadcrumb selectors not found" in record.message for record in caplog.records)
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_special_attributes_empty(self, extractor:AdExtractor) -> None:
+ async def test_extract_special_attributes_empty(self, extractor: AdExtractor) -> None:
"""Test extraction of special attributes when empty."""
- with patch.object(extractor, "web_execute", new_callable = AsyncMock) as mock_web_execute:
- mock_web_execute.return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "ad_attributes": ""
- }
- }
- }
+ with patch.object(extractor, "web_execute", new_callable=AsyncMock) as mock_web_execute:
+ mock_web_execute.return_value = {"universalAnalyticsOpts": {"dimensions": {"ad_attributes": ""}}}
result = await extractor._extract_special_attributes_from_ad_page(mock_web_execute.return_value)
assert result == {}
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_special_attributes_not_empty(self, extractor:AdExtractor) -> None:
+ async def test_extract_special_attributes_not_empty(self, extractor: AdExtractor) -> None:
"""Test extraction of special attributes when not empty."""
special_atts = {
"universalAnalyticsOpts": {
- "dimensions": {
- "ad_attributes": "versand_s:t|color_s:creme|groesse_s:68|condition_s:alright|type_s:accessoires|art_s:maedchen"
- }
+ "dimensions": {"ad_attributes": "versand_s:t|color_s:creme|groesse_s:68|condition_s:alright|type_s:accessoires|art_s:maedchen"}
}
}
result = await extractor._extract_special_attributes_from_ad_page(special_atts)
@@ -1047,9 +979,9 @@ class TestAdExtractorCategory:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_special_attributes_missing_ad_attributes(self, extractor:AdExtractor) -> None:
+ async def test_extract_special_attributes_missing_ad_attributes(self, extractor: AdExtractor) -> None:
"""Test extraction of special attributes when ad_attributes key is missing."""
- belen_conf:dict[str, Any] = {
+ belen_conf: dict[str, Any] = {
"universalAnalyticsOpts": {
"dimensions": {
# ad_attributes key is completely missing
@@ -1064,26 +996,20 @@ class TestAdExtractorContact:
"""Tests for contact information extraction."""
@pytest.fixture
- def extractor(self, test_bot_config:Config) -> AdExtractor:
- browser_mock = MagicMock(spec = Browser)
- config = test_bot_config.with_values({
- "ad_defaults": {
- "description": {
- "prefix": "Test Prefix",
- "suffix": "Test Suffix"
- }
- }
- })
+ def extractor(self, test_bot_config: Config) -> AdExtractor:
+ browser_mock = MagicMock(spec=Browser)
+ config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
return AdExtractor(browser_mock, config)
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_contact_info(self, extractor:AdExtractor) -> None:
+ async def test_extract_contact_info(self, extractor: AdExtractor) -> None:
"""Test extraction of contact information."""
- with patch.object(extractor, "page", MagicMock()), \
- patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \
- patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find:
-
+ with (
+ patch.object(extractor, "page", MagicMock()),
+ patch.object(extractor, "web_text", new_callable=AsyncMock) as mock_web_text,
+ patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find,
+ ):
mock_web_text.side_effect = [
"12345 Berlin - Mitte",
"Example Street 123,",
@@ -1105,29 +1031,26 @@ class TestAdExtractorContact:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_contact_info_timeout(self, extractor:AdExtractor) -> None:
+ async def test_extract_contact_info_timeout(self, extractor: AdExtractor) -> None:
"""Test contact info extraction when elements are not found."""
- with patch.object(extractor, "page", MagicMock()), \
- patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError()), \
- patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError()), \
- pytest.raises(TimeoutError):
-
+ with (
+ patch.object(extractor, "page", MagicMock()),
+ patch.object(extractor, "web_text", new_callable=AsyncMock, side_effect=TimeoutError()),
+ patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError()),
+ pytest.raises(TimeoutError),
+ ):
await extractor._extract_contact_from_ad_page()
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_contact_info_with_phone(self, extractor:AdExtractor) -> None:
+ async def test_extract_contact_info_with_phone(self, extractor: AdExtractor) -> None:
"""Test extraction of contact information including phone number."""
- with patch.object(extractor, "page", MagicMock()), \
- patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \
- patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find:
-
- mock_web_text.side_effect = [
- "12345 Berlin - Mitte",
- "Example Street 123,",
- "Test User",
- "+49(0)1234 567890"
- ]
+ with (
+ patch.object(extractor, "page", MagicMock()),
+ patch.object(extractor, "web_text", new_callable=AsyncMock) as mock_web_text,
+ patch.object(extractor, "web_find", new_callable=AsyncMock) as mock_web_find,
+ ):
+ mock_web_text.side_effect = ["12345 Berlin - Mitte", "Example Street 123,", "Test User", "+49(0)1234 567890"]
phone_element = MagicMock()
mock_web_find.side_effect = [
@@ -1144,44 +1067,36 @@ class TestAdExtractorDownload:
"""Tests for download functionality."""
@pytest.fixture
- def extractor(self, test_bot_config:Config) -> AdExtractor:
- browser_mock = MagicMock(spec = Browser)
- config = test_bot_config.with_values({
- "ad_defaults": {
- "description": {
- "prefix": "Test Prefix",
- "suffix": "Test Suffix"
- }
- }
- })
+ def extractor(self, test_bot_config: Config) -> AdExtractor:
+ browser_mock = MagicMock(spec=Browser)
+ config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
return AdExtractor(browser_mock, config)
@pytest.mark.asyncio
- async def test_download_ad(self, extractor:AdExtractor) -> None:
+ async def test_download_ad(self, extractor: AdExtractor, tmp_path: Path) -> None:
"""Test downloading an ad - directory creation and saving ad data."""
- with patch("pathlib.Path.mkdir"), \
- patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec = True) as mock_save_dict, \
- patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable = AsyncMock) as mock_extract_with_dir:
-
- # Use Path for OS-agnostic path handling
- final_dir = Path("downloaded-ads") / "ad_12345_Test Advertisement Title"
- yaml_path = final_dir / "ad_12345.yaml"
+ # Use tmp_path for OS-agnostic path handling
+ download_base = tmp_path / "downloaded-ads"
+ final_dir = download_base / "ad_12345_Test Advertisement Title"
+ yaml_path = final_dir / "ad_12345.yaml"
+ with (
+ patch("kleinanzeigen_bot.extract.xdg_paths.get_downloaded_ads_path", return_value=download_base),
+ patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec=True) as mock_save_dict,
+ patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable=AsyncMock) as mock_extract_with_dir,
+ ):
mock_extract_with_dir.return_value = (
- AdPartial.model_validate({
- "title": "Test Advertisement Title",
- "description": "Test Description",
- "category": "Dienstleistungen",
- "price": 100,
- "images": [],
- "contact": {
- "name": "Test User",
- "street": "Test Street 123",
- "zipcode": "12345",
- "location": "Test City"
+ AdPartial.model_validate(
+ {
+ "title": "Test Advertisement Title",
+ "description": "Test Description",
+ "category": "Dienstleistungen",
+ "price": 100,
+ "images": [],
+ "contact": {"name": "Test User", "street": "Test Street 123", "zipcode": "12345", "location": "Test City"},
}
- }),
- str(final_dir)
+ ),
+ str(final_dir),
)
await extractor.download_ad(12345)
@@ -1198,15 +1113,15 @@ class TestAdExtractorDownload:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_download_images_no_images(self, extractor:AdExtractor) -> None:
+ async def test_download_images_no_images(self, extractor: AdExtractor) -> None:
"""Test image download when no images are found."""
- with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError):
+ with patch.object(extractor, "web_find", new_callable=AsyncMock, side_effect=TimeoutError):
image_paths = await extractor._download_images_from_ad_page("/some/dir", 12345)
assert len(image_paths) == 0
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_download_images_with_none_url(self, extractor:AdExtractor) -> None:
+ async def test_download_images_with_none_url(self, extractor: AdExtractor) -> None:
"""Test image download when some images have None as src attribute."""
image_box_mock = MagicMock()
@@ -1217,10 +1132,11 @@ class TestAdExtractorDownload:
img_without_url = MagicMock()
img_without_url.attrs = {"src": None}
- with patch.object(extractor, "web_find", new_callable = AsyncMock, return_value = image_box_mock), \
- patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [img_with_url, img_without_url]), \
- patch.object(AdExtractor, "_download_and_save_image_sync", return_value = "/some/dir/ad_12345__img1.jpg"):
-
+ with (
+ patch.object(extractor, "web_find", new_callable=AsyncMock, return_value=image_box_mock),
+ patch.object(extractor, "web_find_all", new_callable=AsyncMock, return_value=[img_with_url, img_without_url]),
+ patch.object(AdExtractor, "_download_and_save_image_sync", return_value="/some/dir/ad_12345__img1.jpg"),
+ ):
image_paths = await extractor._download_images_from_ad_page("/some/dir", 12345)
# Should only download the one valid image (skip the None)
@@ -1229,9 +1145,7 @@ class TestAdExtractorDownload:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_ad_page_info_with_directory_handling_final_dir_exists(
- self, extractor:AdExtractor, tmp_path:Path
- ) -> None:
+ async def test_extract_ad_page_info_with_directory_handling_final_dir_exists(self, extractor: AdExtractor, tmp_path: Path) -> None:
"""Test directory handling when final_dir already exists - it should be deleted."""
base_dir = tmp_path / "downloaded-ads"
base_dir.mkdir()
@@ -1248,34 +1162,34 @@ class TestAdExtractorDownload:
extractor.page = page_mock
with (
- patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
- "Test Title", # Title extraction
- "Test Title", # Second title call for full extraction
- "Description text", # Description
- "03.02.2025" # Creation date
- ]),
- patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
- patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
- patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
- patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
- patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
- patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
- patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
- name = "Test", zipcode = "12345", location = "Berlin"
- )),
+ patch.object(
+ extractor,
+ "web_text",
+ new_callable=AsyncMock,
+ side_effect=[
+ "Test Title", # Title extraction
+ "Test Title", # Second title call for full extraction
+ "Description text", # Description
+ "03.02.2025", # Creation date
+ ],
+ ),
+ patch.object(
+ extractor,
+ "web_execute",
+ new_callable=AsyncMock,
+ return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}},
+ ),
+ patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"),
+ patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}),
+ patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")),
+ patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)),
+ patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False),
+ patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]),
+ patch.object(
+ extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin")
+ ),
):
-
- ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
- base_dir, 12345
- )
+ ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345)
# Verify the old directory was deleted and recreated
assert result_dir == final_dir
@@ -1285,9 +1199,7 @@ class TestAdExtractorDownload:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_ad_page_info_with_directory_handling_rename_enabled(
- self, extractor:AdExtractor, tmp_path:Path
- ) -> None:
+ async def test_extract_ad_page_info_with_directory_handling_rename_enabled(self, extractor: AdExtractor, tmp_path: Path) -> None:
"""Test directory handling when temp_dir exists and rename_existing_folders is True."""
base_dir = tmp_path / "downloaded-ads"
base_dir.mkdir()
@@ -1307,34 +1219,34 @@ class TestAdExtractorDownload:
extractor.page = page_mock
with (
- patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
- "Test Title", # Title extraction
- "Test Title", # Second title call for full extraction
- "Description text", # Description
- "03.02.2025" # Creation date
- ]),
- patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
- patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
- patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
- patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
- patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
- patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
- patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
- name = "Test", zipcode = "12345", location = "Berlin"
- )),
+ patch.object(
+ extractor,
+ "web_text",
+ new_callable=AsyncMock,
+ side_effect=[
+ "Test Title", # Title extraction
+ "Test Title", # Second title call for full extraction
+ "Description text", # Description
+ "03.02.2025", # Creation date
+ ],
+ ),
+ patch.object(
+ extractor,
+ "web_execute",
+ new_callable=AsyncMock,
+ return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}},
+ ),
+ patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"),
+ patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}),
+ patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")),
+ patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)),
+ patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False),
+ patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]),
+ patch.object(
+ extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin")
+ ),
):
-
- ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
- base_dir, 12345
- )
+ ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345)
# Verify the directory was renamed from temp_dir to final_dir
final_dir = base_dir / "ad_12345_Test Title"
@@ -1346,9 +1258,7 @@ class TestAdExtractorDownload:
@pytest.mark.asyncio
# pylint: disable=protected-access
- async def test_extract_ad_page_info_with_directory_handling_use_existing(
- self, extractor:AdExtractor, tmp_path:Path
- ) -> None:
+ async def test_extract_ad_page_info_with_directory_handling_use_existing(self, extractor: AdExtractor, tmp_path: Path) -> None:
"""Test directory handling when temp_dir exists and rename_existing_folders is False (default)."""
base_dir = tmp_path / "downloaded-ads"
base_dir.mkdir()
@@ -1368,34 +1278,34 @@ class TestAdExtractorDownload:
extractor.page = page_mock
with (
- patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
- "Test Title", # Title extraction
- "Test Title", # Second title call for full extraction
- "Description text", # Description
- "03.02.2025" # Creation date
- ]),
- patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
- patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
- patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
- patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
- patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
- patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
- patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
- name = "Test", zipcode = "12345", location = "Berlin"
- )),
+ patch.object(
+ extractor,
+ "web_text",
+ new_callable=AsyncMock,
+ side_effect=[
+ "Test Title", # Title extraction
+ "Test Title", # Second title call for full extraction
+ "Description text", # Description
+ "03.02.2025", # Creation date
+ ],
+ ),
+ patch.object(
+ extractor,
+ "web_execute",
+ new_callable=AsyncMock,
+ return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}},
+ ),
+ patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"),
+ patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}),
+ patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")),
+ patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)),
+ patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False),
+ patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]),
+ patch.object(
+ extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin")
+ ),
):
-
- ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
- base_dir, 12345
- )
+ ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345)
# Verify the existing temp_dir was used (not renamed)
assert result_dir == temp_dir
@@ -1404,7 +1314,7 @@ class TestAdExtractorDownload:
assert ad_cfg.title == "Test Title"
@pytest.mark.asyncio
- async def test_download_ad_with_umlauts_in_title(self, extractor:AdExtractor, tmp_path:Path) -> None:
+ async def test_download_ad_with_umlauts_in_title(self, extractor: AdExtractor, tmp_path: Path) -> None:
"""Test cross-platform Unicode handling for ad titles with umlauts (issue #728).
Verifies that:
@@ -1424,34 +1334,34 @@ class TestAdExtractorDownload:
base_dir.mkdir()
with (
- patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
- title_with_umlauts, # Title extraction
- title_with_umlauts, # Second title call for full extraction
- "Description text", # Description
- "03.02.2025" # Creation date
- ]),
- patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
- "universalAnalyticsOpts": {
- "dimensions": {
- "l3_category_id": "",
- "ad_attributes": ""
- }
- }
- }),
- patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
- patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
- patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
- patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
- patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
- patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
- patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
- name = "Test", zipcode = "12345", location = "Berlin"
- )),
+ patch.object(
+ extractor,
+ "web_text",
+ new_callable=AsyncMock,
+ side_effect=[
+ title_with_umlauts, # Title extraction
+ title_with_umlauts, # Second title call for full extraction
+ "Description text", # Description
+ "03.02.2025", # Creation date
+ ],
+ ),
+ patch.object(
+ extractor,
+ "web_execute",
+ new_callable=AsyncMock,
+ return_value={"universalAnalyticsOpts": {"dimensions": {"l3_category_id": "", "ad_attributes": ""}}},
+ ),
+ patch.object(extractor, "_extract_category_from_ad_page", new_callable=AsyncMock, return_value="160"),
+ patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable=AsyncMock, return_value={}),
+ patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable=AsyncMock, return_value=(None, "NOT_APPLICABLE")),
+ patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable=AsyncMock, return_value=("NOT_APPLICABLE", None, None)),
+ patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable=AsyncMock, return_value=False),
+ patch.object(extractor, "_download_images_from_ad_page", new_callable=AsyncMock, return_value=[]),
+ patch.object(
+ extractor, "_extract_contact_from_ad_page", new_callable=AsyncMock, return_value=ContactPartial(name="Test", zipcode="12345", location="Berlin")
+ ),
):
-
- ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
- base_dir, 12345
- )
+ ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(base_dir, 12345)
# Verify directory was created with NFC-normalized name
assert result_dir.exists()
@@ -1465,12 +1375,11 @@ class TestAdExtractorDownload:
from kleinanzeigen_bot.utils import dicts # noqa: PLC0415
header_string = (
- "# yaml-language-server: $schema="
- "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
+ "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
)
# save_dict normalizes path to NFC, matching the NFC directory name
- dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header = header_string)
+ dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header=header_string)
# Verify file was created successfully (no FileNotFoundError)
assert ad_file_path.exists()
diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py
index cb93875..474e258 100644
--- a/tests/unit/test_init.py
+++ b/tests/unit/test_init.py
@@ -57,13 +57,7 @@ def base_ad_config() -> dict[str, Any]:
"active": True,
"republication_interval": 7,
"created_on": None,
- "contact": {
- "name": "Test User",
- "zipcode": "12345",
- "location": "Test City",
- "street": "",
- "phone": ""
- }
+ "contact": {"name": "Test User", "zipcode": "12345", "location": "Test City", "street": "", "phone": ""},
}
@@ -96,25 +90,19 @@ def remove_fields(config:dict[str, Any], *fields:str) -> dict[str, Any]:
@pytest.fixture
def minimal_ad_config(base_ad_config:dict[str, Any]) -> dict[str, Any]:
"""Provide a minimal ad configuration with only required fields."""
- return remove_fields(
- base_ad_config,
- "id",
- "created_on",
- "shipping_options",
- "special_attributes",
- "contact.street",
- "contact.phone"
- )
+ return remove_fields(base_ad_config, "id", "created_on", "shipping_options", "special_attributes", "contact.street", "contact.phone")
@pytest.fixture
def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]:
"""Provide a centralized mock configuration setup for tests.
This fixture mocks load_config and other essential configuration-related methods."""
- with patch.object(test_bot, "load_config"), \
- patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), \
- patch.object(test_bot, "login", new_callable = AsyncMock), \
- patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request:
+ with (
+ patch.object(test_bot, "load_config"),
+ patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
+ patch.object(test_bot, "login", new_callable = AsyncMock),
+ patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
+ ):
# Mock the web request for published ads
mock_request.return_value = {"content": '{"ads": []}'}
yield
@@ -138,15 +126,64 @@ class TestKleinanzeigenBotInitialization:
with patch("kleinanzeigen_bot.__version__", "1.2.3"):
assert test_bot.get_version() == "1.2.3"
+ def test_finalize_installation_mode_skips_help(self, test_bot:KleinanzeigenBot) -> None:
+ """Ensure finalize_installation_mode returns early for help."""
+ test_bot.command = "help"
+ test_bot.installation_mode = None
+ test_bot.finalize_installation_mode()
+ assert test_bot.installation_mode is None
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"])
+ async def test_run_uses_installation_mode_for_update_checker(self, test_bot:KleinanzeigenBot, command:str) -> None:
+ """Ensure UpdateChecker is initialized with the detected installation mode."""
+ update_checker_calls:list[tuple[Config, str | None]] = []
+
+ class DummyUpdateChecker:
+ def __init__(self, config:Config, installation_mode:str | None) -> None:
+ update_checker_calls.append((config, installation_mode))
+
+ def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None:
+ return None
+
+ def set_installation_mode() -> None:
+ test_bot.installation_mode = "xdg"
+
+ with (
+ patch.object(test_bot, "configure_file_logging"),
+ patch.object(test_bot, "load_config"),
+ patch.object(test_bot, "load_ads", return_value = []),
+ patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
+ patch.object(test_bot, "login", new_callable = AsyncMock),
+ patch.object(test_bot, "download_ads", new_callable = AsyncMock),
+ patch.object(test_bot, "close_browser_session"),
+ patch.object(test_bot, "finalize_installation_mode", side_effect = set_installation_mode),
+ patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker),
+ ):
+ await test_bot.run(["app", command])
+
+ assert update_checker_calls == [(test_bot.config, "xdg")]
+
+ @pytest.mark.asyncio
+ async def test_download_ads_passes_installation_mode(self, test_bot:KleinanzeigenBot) -> None:
+ """Ensure download_ads wires installation mode into AdExtractor."""
+ test_bot.installation_mode = "xdg"
+ test_bot.ads_selector = "all"
+ test_bot.browser = MagicMock()
+
+ extractor_mock = MagicMock()
+ extractor_mock.extract_own_ads_urls = AsyncMock(return_value = [])
+
+ with patch("kleinanzeigen_bot.extract.AdExtractor", return_value = extractor_mock) as mock_extractor:
+ await test_bot.download_ads()
+
+ mock_extractor.assert_called_once_with(test_bot.browser, test_bot.config, "xdg")
+
class TestKleinanzeigenBotLogging:
"""Tests for logging functionality."""
- def test_configure_file_logging_adds_and_removes_handlers(
- self,
- test_bot:KleinanzeigenBot,
- tmp_path:Path
- ) -> None:
+ def test_configure_file_logging_adds_and_removes_handlers(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
"""Ensure file logging registers a handler and cleans it up afterward."""
log_path = tmp_path / "bot.log"
test_bot.log_file_path = str(log_path)
@@ -178,26 +215,24 @@ class TestKleinanzeigenBotLogging:
class TestKleinanzeigenBotCommandLine:
"""Tests for command line argument parsing."""
- @pytest.mark.parametrize(("args", "expected_command", "expected_selector", "expected_keep_old"), [
- (["publish", "--ads=all"], "publish", "all", False),
- (["verify"], "verify", "due", False),
- (["download", "--ads=12345"], "download", "12345", False),
- (["publish", "--force"], "publish", "all", False),
- (["publish", "--keep-old"], "publish", "due", True),
- (["publish", "--ads=all", "--keep-old"], "publish", "all", True),
- (["download", "--ads=new"], "download", "new", False),
- (["publish", "--ads=changed"], "publish", "changed", False),
- (["publish", "--ads=changed,due"], "publish", "changed,due", False),
- (["publish", "--ads=changed,new"], "publish", "changed,new", False),
- (["version"], "version", "due", False),
- ])
+ @pytest.mark.parametrize(
+ ("args", "expected_command", "expected_selector", "expected_keep_old"),
+ [
+ (["publish", "--ads=all"], "publish", "all", False),
+ (["verify"], "verify", "due", False),
+ (["download", "--ads=12345"], "download", "12345", False),
+ (["publish", "--force"], "publish", "all", False),
+ (["publish", "--keep-old"], "publish", "due", True),
+ (["publish", "--ads=all", "--keep-old"], "publish", "all", True),
+ (["download", "--ads=new"], "download", "new", False),
+ (["publish", "--ads=changed"], "publish", "changed", False),
+ (["publish", "--ads=changed,due"], "publish", "changed,due", False),
+ (["publish", "--ads=changed,new"], "publish", "changed,new", False),
+ (["version"], "version", "due", False),
+ ],
+ )
def test_parse_args_handles_valid_arguments(
- self,
- test_bot:KleinanzeigenBot,
- args:list[str],
- expected_command:str,
- expected_selector:str,
- expected_keep_old:bool
+ self, test_bot:KleinanzeigenBot, args:list[str], expected_command:str, expected_selector:str, expected_keep_old:bool
) -> None:
"""Verify that valid command line arguments are parsed correctly."""
test_bot.parse_args(["dummy"] + args) # Add dummy arg to simulate sys.argv[0]
@@ -226,18 +261,11 @@ class TestKleinanzeigenBotCommandLine:
assert exc_info.value.code == 2
assert any(
record.levelno == logging.ERROR
- and (
- "--invalid-option not recognized" in record.getMessage()
- or "Option --invalid-option unbekannt" in record.getMessage()
- )
+ and ("--invalid-option not recognized" in record.getMessage() or "Option --invalid-option unbekannt" in record.getMessage())
for record in caplog.records
)
- assert any(
- ("--invalid-option not recognized" in m)
- or ("Option --invalid-option unbekannt" in m)
- for m in caplog.messages
- )
+ assert any(("--invalid-option not recognized" in m) or ("Option --invalid-option unbekannt" in m) for m in caplog.messages)
def test_parse_args_handles_verbose_flag(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that verbose flag sets correct log level."""
@@ -254,11 +282,7 @@ class TestKleinanzeigenBotCommandLine:
class TestKleinanzeigenBotConfiguration:
"""Tests for configuration loading and validation."""
- def test_load_config_handles_missing_file(
- self,
- test_bot:KleinanzeigenBot,
- test_data_dir:str
- ) -> None:
+ def test_load_config_handles_missing_file(self, test_bot:KleinanzeigenBot, test_data_dir:str) -> None:
"""Verify that loading a missing config file creates default config. No info log is expected anymore."""
config_path = Path(test_data_dir) / "missing_config.yaml"
config_path.unlink(missing_ok = True)
@@ -296,10 +320,14 @@ class TestKleinanzeigenBotAuthentication:
@pytest.mark.asyncio
async def test_is_logged_in_returns_true_with_alternative_element(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns true when logged in with alternative element."""
- with patch.object(test_bot, "web_text", side_effect = [
- TimeoutError(), # First try with mr-medium fails
- "angemeldet als: dummy_user" # Second try with user-email succeeds
- ]):
+ with patch.object(
+ test_bot,
+ "web_text",
+ side_effect = [
+ TimeoutError(), # First try with mr-medium fails
+ "angemeldet als: dummy_user", # Second try with user-email succeeds
+ ],
+ ):
assert await test_bot.is_logged_in() is True
@pytest.mark.asyncio
@@ -311,12 +339,13 @@ class TestKleinanzeigenBotAuthentication:
@pytest.mark.asyncio
async def test_login_flow_completes_successfully(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that normal login flow completes successfully."""
- with patch.object(test_bot, "web_open") as mock_open, \
- patch.object(test_bot, "is_logged_in", side_effect = [False, True]) 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:
-
+ with (
+ patch.object(test_bot, "web_open") as mock_open,
+ patch.object(test_bot, "is_logged_in", side_effect = [False, True]) 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,
+ ):
await test_bot.login()
mock_open.assert_called()
@@ -327,13 +356,14 @@ class TestKleinanzeigenBotAuthentication:
@pytest.mark.asyncio
async def test_login_flow_handles_captcha(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login flow handles captcha correctly."""
- with patch.object(test_bot, "web_open"), \
- patch.object(test_bot, "is_logged_in", side_effect = [False, False, True]), \
- 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:
-
+ with (
+ patch.object(test_bot, "web_open"),
+ patch.object(test_bot, "is_logged_in", side_effect = [False, False, True]),
+ 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,
+ ):
# Mock the sequence of web_find calls:
# First login attempt:
# 1. Captcha iframe found (in check_and_wait_for_captcha)
@@ -366,9 +396,7 @@ class TestKleinanzeigenBotAuthentication:
@pytest.mark.asyncio
async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that captcha detection works correctly."""
- with patch.object(test_bot, "web_find") as mock_find, \
- patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput:
-
+ with patch.object(test_bot, "web_find") as mock_find, patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput:
# Test case 1: Captcha found
mock_find.return_value = AsyncMock()
mock_ainput.return_value = ""
@@ -390,10 +418,11 @@ class TestKleinanzeigenBotAuthentication:
@pytest.mark.asyncio
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, "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:
-
+ with (
+ 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()
@@ -407,10 +436,11 @@ class TestKleinanzeigenBotAuthentication:
@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:
-
+ 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,
+ ):
# Test case 1: No special handling needed
mock_find.side_effect = [TimeoutError(), TimeoutError()] # No phone verification, no GDPR
mock_click.return_value = AsyncMock()
@@ -452,8 +482,7 @@ class TestKleinanzeigenBotLocalization:
def test_show_help_displays_german_text(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that help text is displayed in German when language is German."""
- with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \
- patch("builtins.print") as mock_print:
+ with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, patch("builtins.print") as mock_print:
mock_locale.return_value.language = "de"
test_bot.show_help()
printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list)
@@ -462,8 +491,7 @@ class TestKleinanzeigenBotLocalization:
def test_show_help_displays_english_text(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that help text is displayed in English when language is English."""
- with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \
- patch("builtins.print") as mock_print:
+ with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, patch("builtins.print") as mock_print:
mock_locale.return_value.language = "en"
test_bot.show_help()
printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list)
@@ -493,11 +521,12 @@ class TestKleinanzeigenBotBasics:
payload:dict[str, list[Any]] = {"ads": []}
ad_cfgs:list[tuple[str, Ad, dict[str, Any]]] = [("ad.yaml", Ad.model_validate(base_ad_config), {})]
- with patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock, \
- patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_ad_mock, \
- patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock, \
- patch.object(test_bot, "delete_ad", new_callable = AsyncMock) as delete_ad_mock:
-
+ with (
+ patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock,
+ patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_ad_mock,
+ patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock,
+ patch.object(test_bot, "delete_ad", new_callable = AsyncMock) as delete_ad_mock,
+ ):
await test_bot.publish_ads(ad_cfgs)
web_request_mock.assert_awaited_once_with(f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT")
@@ -641,11 +670,14 @@ class TestKleinanzeigenBotCommands:
async def test_verify_command(self, test_bot:KleinanzeigenBot, tmp_path:Any) -> None:
"""Test verify command with minimal config."""
config_path = Path(tmp_path) / "config.yaml"
- config_path.write_text("""
+ config_path.write_text(
+ """
login:
username: test
password: test
-""", encoding = "utf-8")
+""",
+ encoding = "utf-8",
+ )
test_bot.config_file_path = str(config_path)
await test_bot.run(["script.py", "verify"])
assert test_bot.config.login.username == "test"
@@ -735,9 +767,7 @@ categories:
ad_file = ad_dir / "test_ad.yaml"
# Create a minimal config with empty title to trigger validation
- ad_cfg = minimal_ad_config | {
- "title": ""
- }
+ ad_cfg = minimal_ad_config | {"title": ""}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
@@ -755,9 +785,7 @@ categories:
ad_file = ad_dir / "test_ad.yaml"
# Create config with invalid price type
- ad_cfg = minimal_ad_config | {
- "price_type": "INVALID_TYPE"
- }
+ ad_cfg = minimal_ad_config | {"price_type": "INVALID_TYPE"}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
@@ -775,9 +803,7 @@ categories:
ad_file = ad_dir / "test_ad.yaml"
# Create config with invalid shipping type
- ad_cfg = minimal_ad_config | {
- "shipping_type": "INVALID_TYPE"
- }
+ ad_cfg = minimal_ad_config | {"shipping_type": "INVALID_TYPE"}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
@@ -797,7 +823,7 @@ categories:
# Create config with price for GIVE_AWAY type
ad_cfg = minimal_ad_config | {
"price_type": "GIVE_AWAY",
- "price": 100 # Price should not be set for GIVE_AWAY
+ "price": 100, # Price should not be set for GIVE_AWAY
}
dicts.save_dict(ad_file, ad_cfg)
@@ -818,7 +844,7 @@ categories:
# Create config with FIXED price type but no price
ad_cfg = minimal_ad_config | {
"price_type": "FIXED",
- "price": None # Missing required price for FIXED type
+ "price": None, # Missing required price for FIXED type
}
dicts.save_dict(ad_file, ad_cfg)
@@ -841,20 +867,22 @@ class TestKleinanzeigenBotAdDeletion:
test_bot.page.sleep = AsyncMock()
# Use minimal config since we only need title for deletion by title
- ad_cfg = Ad.model_validate(minimal_ad_config | {
- "title": "Test Title",
- "id": None # Explicitly set id to None for title-based deletion
- })
+ ad_cfg = Ad.model_validate(
+ minimal_ad_config
+ | {
+ "title": "Test Title",
+ "id": None, # Explicitly set id to None for title-based deletion
+ }
+ )
- published_ads = [
- {"title": "Test Title", "id": "67890"},
- {"title": "Other Title", "id": "11111"}
- ]
+ published_ads = [{"title": "Test Title", "id": "67890"}, {"title": "Other Title", "id": "11111"}]
- with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
- patch.object(test_bot, "web_click", new_callable = AsyncMock), \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True):
+ with (
+ patch.object(test_bot, "web_open", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
+ patch.object(test_bot, "web_click", new_callable = AsyncMock),
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
+ ):
mock_find.return_value.attrs = {"content": "some-token"}
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = True)
assert result is True
@@ -867,19 +895,21 @@ class TestKleinanzeigenBotAdDeletion:
test_bot.page.sleep = AsyncMock()
# Create config with ID for deletion by ID
- ad_cfg = Ad.model_validate(minimal_ad_config | {
- "id": "12345" # Fixed: use proper dict key syntax
- })
+ ad_cfg = Ad.model_validate(
+ minimal_ad_config
+ | {
+ "id": "12345" # Fixed: use proper dict key syntax
+ }
+ )
- published_ads = [
- {"title": "Different Title", "id": "12345"},
- {"title": "Other Title", "id": "11111"}
- ]
+ published_ads = [{"title": "Different Title", "id": "12345"}, {"title": "Other Title", "id": "11111"}]
- with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
- patch.object(test_bot, "web_click", new_callable = AsyncMock), \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True):
+ with (
+ patch.object(test_bot, "web_open", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
+ patch.object(test_bot, "web_click", new_callable = AsyncMock),
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
+ ):
mock_find.return_value.attrs = {"content": "some-token"}
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False)
assert result is True
@@ -892,20 +922,17 @@ class TestKleinanzeigenBotAdDeletion:
test_bot.page.sleep = AsyncMock()
# Create config with ID for deletion by ID
- ad_cfg = Ad.model_validate(minimal_ad_config | {
- "id": "12345"
- })
+ ad_cfg = Ad.model_validate(minimal_ad_config | {"id": "12345"})
- published_ads = [
- {"title": "Different Title", "id": "12345"},
- {"title": "Other Title", "id": "11111"}
- ]
+ published_ads = [{"title": "Different Title", "id": "12345"}, {"title": "Other Title", "id": "11111"}]
- with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
- patch.object(test_bot, "web_click", new_callable = AsyncMock), \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
- patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request:
+ with (
+ patch.object(test_bot, "web_open", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
+ patch.object(test_bot, "web_click", new_callable = AsyncMock),
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
+ patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
+ ):
# Mock non-string CSRF token to test str() conversion
mock_find.return_value.attrs = {"content": 12345} # Non-string token
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False)
@@ -923,20 +950,12 @@ class TestKleinanzeigenBotAdRepublication:
def test_check_ad_republication_with_changes(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that ads with changes are marked for republication."""
# Mock the description config to prevent modification of the description
- test_bot.config.ad_defaults = AdDefaults.model_validate({
- "description": {
- "prefix": "",
- "suffix": ""
- }
- })
+ test_bot.config.ad_defaults = AdDefaults.model_validate({"description": {"prefix": "", "suffix": ""}})
# Create ad config with all necessary fields for republication
- ad_cfg = Ad.model_validate(base_ad_config | {
- "id": "12345",
- "updated_on": "2024-01-01T00:00:01",
- "created_on": "2024-01-01T00:00:01",
- "description": "Changed description"
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config | {"id": "12345", "updated_on": "2024-01-01T00:00:01", "created_on": "2024-01-01T00:00:01", "description": "Changed description"}
+ )
# Create a temporary directory and file
with tempfile.TemporaryDirectory() as temp_dir:
@@ -960,11 +979,7 @@ class TestKleinanzeigenBotAdRepublication:
three_days_ago = (current_time - timedelta(days = 3)).isoformat()
# Create ad config with timestamps for republication check
- ad_cfg = Ad.model_validate(base_ad_config | {
- "id": "12345",
- "updated_on": three_days_ago,
- "created_on": three_days_ago
- })
+ ad_cfg = Ad.model_validate(base_ad_config | {"id": "12345", "updated_on": three_days_ago, "created_on": three_days_ago})
# Calculate hash before making the copy to ensure they match
ad_cfg_orig = ad_cfg.model_dump()
@@ -973,8 +988,10 @@ class TestKleinanzeigenBotAdRepublication:
# Mock the config to prevent actual file operations
test_bot.config.ad_files = ["test.yaml"]
- with patch("kleinanzeigen_bot.utils.dicts.load_dict_if_exists", return_value = ad_cfg_orig), \
- patch("kleinanzeigen_bot.utils.dicts.load_dict", return_value = {}): # Mock ad_fields.yaml
+ with (
+ patch("kleinanzeigen_bot.utils.dicts.load_dict_if_exists", return_value = ad_cfg_orig),
+ patch("kleinanzeigen_bot.utils.dicts.load_dict", return_value = {}),
+ ): # Mock ad_fields.yaml
ads_to_publish = test_bot.load_ads()
assert len(ads_to_publish) == 0 # No ads should be marked for republication
@@ -991,11 +1008,14 @@ class TestKleinanzeigenBotShippingOptions:
test_bot.page.evaluate = AsyncMock()
# Create ad config with specific shipping options
- ad_cfg = Ad.model_validate(base_ad_config | {
- "shipping_options": ["DHL_2", "Hermes_Päckchen"],
- "updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError
- "created_on": "2024-01-01T00:00:00" # Add updated_on for consistency
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config
+ | {
+ "shipping_options": ["DHL_2", "Hermes_Päckchen"],
+ "updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError
+ "created_on": "2024-01-01T00:00:00", # Add updated_on for consistency
+ }
+ )
# Create the original ad config and published ads list
ad_cfg.update_content_hash() # Add content hash to prevent republication
@@ -1003,10 +1023,7 @@ class TestKleinanzeigenBotShippingOptions:
published_ads:list[dict[str, Any]] = []
# Set up default config values needed for the test
- test_bot.config.publishing = PublishingConfig.model_validate({
- "delete_old_ads": "BEFORE_PUBLISH",
- "delete_old_ads_by_title": False
- })
+ test_bot.config.publishing = PublishingConfig.model_validate({"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False})
# Create temporary file path
ad_file = Path(tmp_path) / "test_ad.yaml"
@@ -1031,20 +1048,21 @@ class TestKleinanzeigenBotShippingOptions:
category_path_elem.apply = AsyncMock(return_value = "Test Category")
# Mock the necessary web interaction methods
- with patch.object(test_bot, "web_execute", side_effect = mock_web_execute), \
- patch.object(test_bot, "web_click", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
- patch.object(test_bot, "web_select", new_callable = AsyncMock), \
- patch.object(test_bot, "web_input", new_callable = AsyncMock), \
- patch.object(test_bot, "web_open", new_callable = AsyncMock), \
- patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
- patch.object(test_bot, "web_request", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find_all", new_callable = AsyncMock), \
- patch.object(test_bot, "web_await", new_callable = AsyncMock), \
- patch("builtins.input", return_value = ""), \
- patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock):
-
+ with (
+ patch.object(test_bot, "web_execute", side_effect = mock_web_execute),
+ patch.object(test_bot, "web_click", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
+ patch.object(test_bot, "web_select", new_callable = AsyncMock),
+ patch.object(test_bot, "web_input", new_callable = AsyncMock),
+ patch.object(test_bot, "web_open", new_callable = AsyncMock),
+ patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
+ patch.object(test_bot, "web_request", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find_all", new_callable = AsyncMock),
+ patch.object(test_bot, "web_await", new_callable = AsyncMock),
+ patch("builtins.input", return_value = ""),
+ patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
+ ):
# Mock web_find to simulate element detection
async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_value == "meta[name=_csrf]":
@@ -1076,21 +1094,17 @@ class TestKleinanzeigenBotShippingOptions:
async def test_cross_drive_path_fallback_windows(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that cross-drive path handling falls back to absolute path on Windows."""
# Create ad config
- ad_cfg = Ad.model_validate(base_ad_config | {
- "updated_on": "2024-01-01T00:00:00",
- "created_on": "2024-01-01T00:00:00",
- "auto_price_reduction": {
- "enabled": True,
- "strategy": "FIXED",
- "amount": 10,
- "min_price": 50,
- "delay_reposts": 0,
- "delay_days": 0
- },
- "price": 100,
- "repost_count": 1,
- "price_reduction_count": 0
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config
+ | {
+ "updated_on": "2024-01-01T00:00:00",
+ "created_on": "2024-01-01T00:00:00",
+ "auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 10, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
+ "price": 100,
+ "repost_count": 1,
+ "price_reduction_count": 0,
+ }
+ )
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
@@ -1111,10 +1125,12 @@ class TestKleinanzeigenBotShippingOptions:
raise _SentinelException("Abort early for test")
# Mock Path to use PureWindowsPath for testing cross-drive behavior
- with patch("kleinanzeigen_bot.Path", PureWindowsPath), \
- patch("kleinanzeigen_bot.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction), \
- patch.object(test_bot, "web_open", new_callable = AsyncMock), \
- patch.object(test_bot, "delete_ad", new_callable = AsyncMock):
+ with (
+ patch("kleinanzeigen_bot.Path", PureWindowsPath),
+ patch("kleinanzeigen_bot.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction),
+ patch.object(test_bot, "web_open", new_callable = AsyncMock),
+ patch.object(test_bot, "delete_ad", new_callable = AsyncMock),
+ ):
# Call publish_ad and expect sentinel exception
try:
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
@@ -1128,30 +1144,21 @@ class TestKleinanzeigenBotShippingOptions:
assert recorded_path[0] == ad_file, f"Expected absolute path fallback, got: {recorded_path[0]}"
@pytest.mark.asyncio
- async def test_auto_price_reduction_only_on_replace_not_update(
- self,
- test_bot:KleinanzeigenBot,
- base_ad_config:dict[str, Any],
- tmp_path:Path
- ) -> None:
+ async def test_auto_price_reduction_only_on_replace_not_update(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Path) -> None:
"""Test that auto price reduction is ONLY applied on REPLACE mode, not UPDATE."""
# Create ad with auto price reduction enabled
- ad_cfg = Ad.model_validate(base_ad_config | {
- "id": 12345,
- "price": 200,
- "auto_price_reduction": {
- "enabled": True,
- "strategy": "FIXED",
- "amount": 50,
- "min_price": 50,
- "delay_reposts": 0,
- "delay_days": 0
- },
- "repost_count": 1,
- "price_reduction_count": 0,
- "updated_on": "2024-01-01T00:00:00",
- "created_on": "2024-01-01T00:00:00"
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config
+ | {
+ "id": 12345,
+ "price": 200,
+ "auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 50, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
+ "repost_count": 1,
+ "price_reduction_count": 0,
+ "updated_on": "2024-01-01T00:00:00",
+ "created_on": "2024-01-01T00:00:00",
+ }
+ )
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
@@ -1159,22 +1166,23 @@ class TestKleinanzeigenBotShippingOptions:
with patch("kleinanzeigen_bot.apply_auto_price_reduction") as mock_apply:
# Mock other dependencies
mock_response = {"statusCode": 200, "statusMessage": "OK", "content": "{}"}
- with patch.object(test_bot, "web_find", 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_open", new_callable = AsyncMock), \
- patch.object(test_bot, "web_select", new_callable = AsyncMock), \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), \
- patch.object(test_bot, "web_await", new_callable = AsyncMock), \
- patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
- patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = mock_response), \
- patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response), \
- patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock), \
- patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), \
- patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), \
- patch("builtins.input", return_value = ""), \
- patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""):
-
+ with (
+ patch.object(test_bot, "web_find", 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_open", new_callable = AsyncMock),
+ patch.object(test_bot, "web_select", new_callable = AsyncMock),
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
+ patch.object(test_bot, "web_await", new_callable = AsyncMock),
+ patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
+ patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = mock_response),
+ patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response),
+ patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
+ patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
+ patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
+ patch("builtins.input", return_value = ""),
+ patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""),
+ ):
test_bot.page = MagicMock()
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
@@ -1194,15 +1202,18 @@ class TestKleinanzeigenBotShippingOptions:
async def test_special_attributes_with_non_string_values(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that special attributes with non-string values are converted to strings."""
# Create ad config with string special attributes first (to pass validation)
- ad_cfg = Ad.model_validate(base_ad_config | {
- "special_attributes": {
- "art_s": "12345", # String value initially
- "condition_s": "67890", # String value initially
- "color_s": "red" # String value
- },
- "updated_on": "2024-01-01T00:00:00",
- "created_on": "2024-01-01T00:00:00"
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config
+ | {
+ "special_attributes": {
+ "art_s": "12345", # String value initially
+ "condition_s": "67890", # String value initially
+ "color_s": "red", # String value
+ },
+ "updated_on": "2024-01-01T00:00:00",
+ "created_on": "2024-01-01T00:00:00",
+ }
+ )
# Now modify the special attributes to non-string values to test str() conversion
# This simulates the scenario where the values come from external sources as non-strings
@@ -1234,11 +1245,12 @@ class TestKleinanzeigenBotShippingOptions:
color_s_elem.local_name = "select"
# Mock the necessary web interaction methods
- with patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
- patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select, \
- patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
- patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock) as mock_set_condition:
-
+ with (
+ patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
+ patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
+ patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
+ patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock) as mock_set_condition,
+ ):
# Mock web_find to simulate element detection
async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
# Handle XPath queries for special attributes
@@ -1284,23 +1296,22 @@ class TestKleinanzeigenBotUrlConstruction:
class TestKleinanzeigenBotPrefixSuffix:
"""Tests for description prefix and suffix functionality."""
+
# pylint: disable=protected-access
- def test_description_prefix_suffix_handling(
- self,
- test_bot_config:Config,
- description_test_cases:list[tuple[dict[str, Any], str, str]]
- ) -> None:
+ def test_description_prefix_suffix_handling(self, test_bot_config:Config, description_test_cases:list[tuple[dict[str, Any], str, str]]) -> None:
"""Test handling of description prefix/suffix in various configurations."""
for config, raw_description, expected_description in description_test_cases:
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values(config)
- ad_cfg = test_bot.load_ad({
- "description": raw_description,
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": raw_description,
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
# Access private method using the correct name mangling
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
@@ -1309,18 +1320,15 @@ class TestKleinanzeigenBotPrefixSuffix:
def test_description_length_validation(self, test_bot_config:Config) -> None:
"""Test that long descriptions with affixes raise appropriate error."""
test_bot = KleinanzeigenBot()
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description_prefix": "P" * 1000,
- "description_suffix": "S" * 1000
+ test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "P" * 1000, "description_suffix": "S" * 1000}})
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "D" * 2001, # This plus affixes will exceed 4000 chars
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
}
- })
- ad_cfg = test_bot.load_ad({
- "description": "D" * 2001, # This plus affixes will exceed 4000 chars
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ )
with pytest.raises(AssertionError) as exc_info:
getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
@@ -1338,12 +1346,14 @@ class TestKleinanzeigenBotDescriptionHandling:
test_bot.config = test_bot_config
# Test with a simple ad config
- ad_cfg = test_bot.load_ad({
- "description": "Test Description",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Test Description",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
# The description should be returned as-is without any prefix/suffix
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
@@ -1352,19 +1362,16 @@ class TestKleinanzeigenBotDescriptionHandling:
def test_description_with_only_new_format_affixes(self, test_bot_config:Config) -> None:
"""Test that description works with only new format affixes in config."""
test_bot = KleinanzeigenBot()
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description_prefix": "Prefix: ",
- "description_suffix": " :Suffix"
- }
- })
+ test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "Prefix: ", "description_suffix": " :Suffix"}})
- ad_cfg = test_bot.load_ad({
- "description": "Test Description",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Test Description",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Prefix: Test Description :Suffix"
@@ -1372,23 +1379,24 @@ class TestKleinanzeigenBotDescriptionHandling:
def test_description_with_mixed_config_formats(self, test_bot_config:Config) -> None:
"""Test that description works with both old and new format affixes in config."""
test_bot = KleinanzeigenBot()
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description_prefix": "New Prefix: ",
- "description_suffix": " :New Suffix",
- "description": {
- "prefix": "Old Prefix: ",
- "suffix": " :Old Suffix"
+ test_bot.config = test_bot_config.with_values(
+ {
+ "ad_defaults": {
+ "description_prefix": "New Prefix: ",
+ "description_suffix": " :New Suffix",
+ "description": {"prefix": "Old Prefix: ", "suffix": " :Old Suffix"},
}
}
- })
+ )
- ad_cfg = test_bot.load_ad({
- "description": "Test Description",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Test Description",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "New Prefix: Test Description :New Suffix"
@@ -1396,21 +1404,18 @@ class TestKleinanzeigenBotDescriptionHandling:
def test_description_with_ad_level_affixes(self, test_bot_config:Config) -> None:
"""Test that ad-level affixes take precedence over config affixes."""
test_bot = KleinanzeigenBot()
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description_prefix": "Config Prefix: ",
- "description_suffix": " :Config Suffix"
- }
- })
+ test_bot.config = test_bot_config.with_values({"ad_defaults": {"description_prefix": "Config Prefix: ", "description_suffix": " :Config Suffix"}})
- ad_cfg = test_bot.load_ad({
- "description": "Test Description",
- "description_prefix": "Ad Prefix: ",
- "description_suffix": " :Ad Suffix",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Test Description",
+ "description_prefix": "Ad Prefix: ",
+ "description_suffix": " :Ad Suffix",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Ad Prefix: Test Description :Ad Suffix"
@@ -1418,23 +1423,18 @@ class TestKleinanzeigenBotDescriptionHandling:
def test_description_with_none_values(self, test_bot_config:Config) -> None:
"""Test that None values in affixes are handled correctly."""
test_bot = KleinanzeigenBot()
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description_prefix": None,
- "description_suffix": None,
- "description": {
- "prefix": None,
- "suffix": None
- }
- }
- })
+ test_bot.config = test_bot_config.with_values(
+ {"ad_defaults": {"description_prefix": None, "description_suffix": None, "description": {"prefix": None, "suffix": None}}}
+ )
- ad_cfg = test_bot.load_ad({
- "description": "Test Description",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Test Description",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Test Description"
@@ -1444,12 +1444,14 @@ class TestKleinanzeigenBotDescriptionHandling:
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config
- ad_cfg = test_bot.load_ad({
- "description": "Contact: test@example.com",
- "active": True,
- "title": "0123456789",
- "category": "whatever",
- })
+ ad_cfg = test_bot.load_ad(
+ {
+ "description": "Contact: test@example.com",
+ "active": True,
+ "title": "0123456789",
+ "category": "whatever",
+ }
+ )
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Contact: test(at)example.com"
@@ -1463,23 +1465,12 @@ class TestKleinanzeigenBotChangedAds:
# Set up the bot with the 'changed' selector
test_bot = KleinanzeigenBot()
test_bot.ads_selector = "changed"
- test_bot.config = test_bot_config.with_values({
- "ad_defaults": {
- "description": {
- "prefix": "",
- "suffix": ""
- }
- }
- })
+ test_bot.config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "", "suffix": ""}}})
# Create a changed ad
- ad_cfg = Ad.model_validate(base_ad_config | {
- "id": "12345",
- "title": "Changed Ad",
- "updated_on": "2024-01-01T00:00:00",
- "created_on": "2024-01-01T00:00:00",
- "active": True
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config | {"id": "12345", "title": "Changed Ad", "updated_on": "2024-01-01T00:00:00", "created_on": "2024-01-01T00:00:00", "active": True}
+ )
# Calculate hash for changed_ad and add it to the config
# Then modify the ad to simulate a change
@@ -1503,10 +1494,13 @@ class TestKleinanzeigenBotChangedAds:
test_bot.config.ad_files = ["ads/*.yaml"]
# Mock the loading of the ad configuration
- with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [
- changed_ad, # First call returns the changed ad
- {} # Second call for ad_fields.yaml
- ]):
+ with patch(
+ "kleinanzeigen_bot.utils.dicts.load_dict",
+ side_effect = [
+ changed_ad, # First call returns the changed ad
+ {}, # Second call for ad_fields.yaml
+ ],
+ ):
ads_to_publish = test_bot.load_ads()
# The changed ad should be loaded
@@ -1522,14 +1516,17 @@ class TestKleinanzeigenBotChangedAds:
current_time = misc.now()
old_date = (current_time - timedelta(days = 10)).isoformat() # Past republication interval
- ad_cfg = Ad.model_validate(base_ad_config | {
- "id": "12345",
- "title": "Changed Ad",
- "updated_on": old_date,
- "created_on": old_date,
- "republication_interval": 7, # Due for republication after 7 days
- "active": True
- })
+ ad_cfg = Ad.model_validate(
+ base_ad_config
+ | {
+ "id": "12345",
+ "title": "Changed Ad",
+ "updated_on": old_date,
+ "created_on": old_date,
+ "republication_interval": 7, # Due for republication after 7 days
+ "active": True,
+ }
+ )
changed_ad = ad_cfg.model_dump()
# Create temporary directory and file
@@ -1546,10 +1543,13 @@ class TestKleinanzeigenBotChangedAds:
test_bot.config.ad_files = ["ads/*.yaml"]
# Mock the loading of the ad configuration
- with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [
- changed_ad, # First call returns the changed ad
- {} # Second call for ad_fields.yaml
- ]):
+ with patch(
+ "kleinanzeigen_bot.utils.dicts.load_dict",
+ side_effect = [
+ changed_ad, # First call returns the changed ad
+ {}, # Second call for ad_fields.yaml
+ ],
+ ):
ads_to_publish = test_bot.load_ads()
# The changed ad should be loaded with 'due' selector because it's due for republication