feat: preview auto price reduction decisions in verify command (#829)

This commit is contained in:
Jens
2026-02-17 13:34:09 +01:00
committed by GitHub
parent b2cda15466
commit 4ae46f7aa4
5 changed files with 63 additions and 20 deletions

View File

@@ -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. **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 ```yaml
auto_price_reduction: auto_price_reduction:
@@ -323,7 +323,7 @@ republication_interval: 7
## Troubleshooting ## Troubleshooting
- **Schema validation errors**: Run `kleinanzeigen-bot verify` (binary) or `pdm run app verify` (source) to see which fields fail validation. - **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). - **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`. - **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_`). - **File naming/prefix mismatch**: Ensure ad files match your `ad_files` glob and prefix (default `ad_`).

View File

@@ -75,7 +75,7 @@ def _repost_cycle_ready(
return False return False
if eligible_cycles <= applied_cycles: 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 "Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts", ad_file_relative, applied_cycles, eligible_cycles
) )
return False 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 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: 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. 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 :param ad_file_relative: Relative path to the ad file for logging
""" """
if not ad_cfg.auto_price_reduction.enabled: if not ad_cfg.auto_price_reduction.enabled:
LOG.debug("Auto price reduction: not configured for [%s]", ad_file_relative)
return return
base_price = ad_cfg.price 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): 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 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." "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", " repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s",
ad_file_relative, 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 return
if not _day_delay_elapsed(ad_cfg, ad_file_relative, day_delay_state = day_delay_state): 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." "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", " elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s",
ad_file_relative, 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 return
LOG.info( LOG.debug(
"Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).", "Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).",
ad_file_relative, ad_file_relative,
eligible_cycles, eligible_cycles,
@@ -383,7 +392,11 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
# Check for updates on startup # Check for updates on startup
checker = UpdateChecker(self.config, self._update_check_state_path) checker = UpdateChecker(self.config, self._update_check_state_path)
checker.check_for_updates() 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("############################################")
LOG.info("DONE: No configuration errors found.") LOG.info("DONE: No configuration errors found.")
LOG.info("############################################") 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}") 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)) ad_cfg.images = list(dict.fromkeys(images))
LOG.info(" -> LOADED: ad [%s]", ad_file_relative)
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)) 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) # Apply auto price reduction only for REPLACE operations (actual reposts)
# This ensures price reductions only happen on republish, not on UPDATE # This ensures price reductions only happen on republish, not on UPDATE
try: apply_auto_price_reduction(ad_cfg, ad_cfg_orig, _relative_ad_path(ad_file, self.config_file_path))
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)
LOG.info("Publishing ad '%s'...", ad_cfg.title) LOG.info("Publishing ad '%s'...", ad_cfg.title)
await self.web_open(f"{self.root_url}/p-anzeige-aufgeben-schritt2.html") await self.web_open(f"{self.root_url}/p-anzeige-aufgeben-schritt2.html")

View File

@@ -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 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." " -> 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]." "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" "Loaded %s": "%s geladen"
"ad": "Anzeige" "ad": "Anzeige"
@@ -164,11 +165,6 @@ kleinanzeigen_bot/__init__.py:
apply_auto_price_reduction: 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 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 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 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" "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: _repost_cycle_ready:

View File

@@ -236,3 +236,41 @@ def test_cli_subcommands_with_config_formats(
assert result.returncode == 0 assert result.returncode == 0
elif subcommand == "diagnose": elif subcommand == "diagnose":
assert "browser connection diagnostics" in out or "browser-verbindungsdiagnose" in out, f"Expected diagnostic output for 'diagnose'.\n{out}" 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}"

View File

@@ -275,7 +275,7 @@ def test_apply_auto_price_reduction_respects_repost_delay(caplog:pytest.LogCaptu
ad_orig:dict[str, Any] = {} 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") apply_auto_price_reduction(ad_cfg, ad_orig, "ad_delay.yaml")
assert ad_cfg.price == 200 assert ad_cfg.price == 200