diff --git a/docs/AD_CONFIGURATION.md b/docs/AD_CONFIGURATION.md index d88e6b2..4742c9d 100644 --- a/docs/AD_CONFIGURATION.md +++ b/docs/AD_CONFIGURATION.md @@ -111,9 +111,9 @@ When `auto_price_reduction.enabled` is set to `true`, the bot lowers the configu **Note:** `repost_count` and price reduction counters are only incremented and persisted after a successful publish. Failed publish attempts do not advance the counters. -When automatic price reduction is enabled, each `publish` run logs a concise decision line that states whether the reduction was applied or skipped and why (repost delay/day delay), including the earliest next eligible reduction condition. +When automatic price reduction is enabled, each `publish` run logs one clear INFO message per ad summarizing the outcome—whether the price was reduced, kept, or the reduction was delayed (and why). The `verify` command also previews these outcomes for all configured ads so you can validate your pricing configuration without triggering a publish cycle. Ads without `auto_price_reduction` configured are silently skipped at default log level. -If you run with `-v` / `--verbose`, the bot additionally logs the full cycle-by-cycle calculation trace (base price, reduction value, rounded step result, and floor clamp). +If you run with `-v` / `--verbose`, the bot additionally logs structured decision details (repost counts, cycle state, day delay, reference timestamps) and the full cycle-by-cycle calculation trace (base price, reduction value, rounded step result, and floor clamp). ```yaml auto_price_reduction: @@ -323,7 +323,7 @@ republication_interval: 7 ## Troubleshooting - **Schema validation errors**: Run `kleinanzeigen-bot verify` (binary) or `pdm run app verify` (source) to see which fields fail validation. -- **Price reduction not applying**: Confirm `auto_price_reduction.enabled` is `true`, `min_price` is set, and you are using `publish` (not `update`). Remember ad-level values override `ad_defaults`. +- **Price reduction not applying**: Confirm `auto_price_reduction.enabled` is `true`, `min_price` is set, and you are using `publish` (not `update`). Run `kleinanzeigen-bot verify` to preview outcomes, or add `-v` for detailed decision data including repost/day-delay state. Remember ad-level values override `ad_defaults`. - **Shipping configuration issues**: Use `shipping_type: SHIPPING` when setting `shipping_costs` or `shipping_options`, and pick options from a single size group (S/M/L). - **Category not found**: Verify the category name or ID and check any custom mappings in `config.yaml`. - **File naming/prefix mismatch**: Ensure ad files match your `ad_files` glob and prefix (default `ad_`). diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 93bf464..4556ecb 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -75,7 +75,7 @@ def _repost_cycle_ready( return False if eligible_cycles <= applied_cycles: - LOG.debug( + LOG.info( "Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts", ad_file_relative, applied_cycles, eligible_cycles ) return False @@ -150,6 +150,14 @@ def _day_delay_state(ad_cfg:Ad) -> tuple[bool, int | None, datetime | None]: return elapsed_days >= delay_days, elapsed_days, reference +def _relative_ad_path(ad_file:str, config_file_path:str) -> str: + """Compute an ad file path relative to the config directory, falling back to the absolute path.""" + try: + return str(Path(ad_file).relative_to(Path(config_file_path).parent)) + except ValueError: + return ad_file + + 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. @@ -162,6 +170,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r :param ad_file_relative: Relative path to the ad file for logging """ if not ad_cfg.auto_price_reduction.enabled: + LOG.debug("Auto price reduction: not configured for [%s]", ad_file_relative) return base_price = ad_cfg.price @@ -183,7 +192,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r if not _repost_cycle_ready(ad_cfg, ad_file_relative, repost_state = repost_state): next_repost = delay_reposts + 1 if total_reposts <= delay_reposts else delay_reposts + applied_cycles + 1 - LOG.info( + LOG.debug( "Auto price reduction decision for [%s]: skipped (repost delay). next reduction earliest at repost >= %s and day delay %s/%s days." " repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s", ad_file_relative, @@ -198,7 +207,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r return if not _day_delay_elapsed(ad_cfg, ad_file_relative, day_delay_state = day_delay_state): - LOG.info( + LOG.debug( "Auto price reduction decision for [%s]: skipped (day delay). next reduction earliest when elapsed_days >= %s." " elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s", ad_file_relative, @@ -211,7 +220,7 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r ) return - LOG.info( + LOG.debug( "Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).", ad_file_relative, eligible_cycles, @@ -383,7 +392,11 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 # Check for updates on startup checker = UpdateChecker(self.config, self._update_check_state_path) checker.check_for_updates() - self.load_ads() + self.ads_selector = "all" + if ads := self.load_ads(exclude_ads_with_id = False): + for ad_file, ad_cfg, ad_cfg_orig in ads: + ad_file_relative = _relative_ad_path(ad_file, self.config_file_path) + apply_auto_price_reduction(ad_cfg, ad_cfg_orig, ad_file_relative) LOG.info("############################################") LOG.info("DONE: No configuration errors found.") LOG.info("############################################") @@ -923,6 +936,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 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)) + LOG.info(" -> LOADED: ad [%s]", ad_file_relative) ads.append((ad_file, ad_cfg, ad_cfg_orig)) LOG.info("Loaded %s", pluralize("ad", ads)) @@ -1552,12 +1566,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 # Apply auto price reduction only for REPLACE operations (actual reposts) # This ensures price reductions only happen on republish, not on UPDATE - try: - ad_file_relative = str(Path(ad_file).relative_to(Path(self.config_file_path).parent)) - except ValueError: - # On Windows, relative_to fails when paths are on different drives - ad_file_relative = ad_file - apply_auto_price_reduction(ad_cfg, ad_cfg_orig, ad_file_relative) + apply_auto_price_reduction(ad_cfg, ad_cfg_orig, _relative_ad_path(ad_file, self.config_file_path)) LOG.info("Publishing ad '%s'...", ad_cfg.title) await self.web_open(f"{self.root_url}/p-anzeige-aufgeben-schritt2.html") diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 927b73c..7a75e25 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -60,6 +60,7 @@ kleinanzeigen_bot/__init__.py: " -> SKIPPED: ad [%s] is not in list of given ids.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht in der Liste der angegebenen IDs." " -> SKIPPED: ad [%s] is not new. already has an id assigned.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen." "Category [%s] unknown. Using category [%s] with ID [%s] instead.": "Kategorie [%s] unbekannt. Verwende stattdessen Kategorie [%s] mit ID [%s]." + " -> LOADED: ad [%s]": " -> GELADEN: Anzeige [%s]" "Loaded %s": "%s geladen" "ad": "Anzeige" @@ -164,11 +165,6 @@ kleinanzeigen_bot/__init__.py: apply_auto_price_reduction: "Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert." "Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten." - ? "Auto price reduction decision for [%s]: skipped (repost delay). next reduction earliest at repost >= %s and day delay %s/%s days. repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s" - : "Entscheidung automatische Preisreduzierung für [%s]: übersprungen (Repost-Verzögerung). Nächste Reduktion frühestens bei Repost >= %s und Tagesverzögerung %s/%s Tage. repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s" - ? "Auto price reduction decision for [%s]: skipped (day delay). next reduction earliest when elapsed_days >= %s. elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s" - : "Entscheidung automatische Preisreduzierung für [%s]: übersprungen (Tagesverzögerung). Nächste Reduktion frühestens bei elapsed_days >= %s. elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s" - "Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).": "Entscheidung automatische Preisreduzierung für [%s]: wird jetzt angewendet (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s)." "Auto price reduction applied: %s -> %s after %s reduction cycles": "Automatische Preisreduzierung angewendet: %s -> %s nach %s Reduktionszyklen" "Auto price reduction kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen" _repost_cycle_ready: diff --git a/tests/smoke/test_smoke_health.py b/tests/smoke/test_smoke_health.py index 10b13e8..b43f557 100644 --- a/tests/smoke/test_smoke_health.py +++ b/tests/smoke/test_smoke_health.py @@ -236,3 +236,41 @@ def test_cli_subcommands_with_config_formats( assert result.returncode == 0 elif subcommand == "diagnose": assert "browser connection diagnostics" in out or "browser-verbindungsdiagnose" in out, f"Expected diagnostic output for 'diagnose'.\n{out}" + + +@pytest.mark.smoke +def test_verify_shows_auto_price_reduction_decisions(tmp_path:Path, test_bot_config:Config) -> None: + """Smoke: verify command previews auto price reduction decisions for all configured ads.""" + config_dict = test_bot_config.model_dump() + config_dict["ad_files"] = ["./**/ad_*.yaml"] + config_path = tmp_path / "config.yaml" + yaml = YAML(typ = "unsafe", pure = True) + with open(config_path, "w", encoding = "utf-8") as f: + yaml.dump(config_dict, f) + + ad_dir = tmp_path / "ads" + ad_dir.mkdir() + ad_yaml = ad_dir / "ad_test_pricing.yaml" + ad_yaml.write_text( + "title: Test Auto Pricing Ad\n" + "description: A test ad to verify auto price reduction preview\n" + "category: 161/gezielt\n" + "price: 200\n" + "price_type: FIXED\n" + "repost_count: 3\n" + "auto_price_reduction:\n" + " enabled: true\n" + " strategy: PERCENTAGE\n" + " amount: 10\n" + " min_price: 100\n" + " delay_reposts: 0\n" + " delay_days: 0\n", + encoding = "utf-8", + ) + + args = ["verify", "--config", str(config_path), "--workspace-mode", "portable"] + result = invoke_cli(args, cwd = tmp_path) + assert result.returncode == 0 + out = (result.stdout + "\n" + result.stderr).lower() + assert "no configuration errors found" in out, f"Expected 'no configuration errors found' in output.\n{out}" + assert "auto price reduction applied" in out, f"Expected auto price reduction applied log in output.\n{out}" diff --git a/tests/unit/test_price_reduction.py b/tests/unit/test_price_reduction.py index 6f55911..2845e01 100644 --- a/tests/unit/test_price_reduction.py +++ b/tests/unit/test_price_reduction.py @@ -275,7 +275,7 @@ def test_apply_auto_price_reduction_respects_repost_delay(caplog:pytest.LogCaptu ad_orig:dict[str, Any] = {} - with caplog.at_level(logging.INFO): + with caplog.at_level(logging.DEBUG): apply_auto_price_reduction(ad_cfg, ad_orig, "ad_delay.yaml") assert ad_cfg.price == 200