Files
willhaben-tracker/docs/phase-2/task-price-filters.md
T

9.1 KiB

Task: Price range filters per keyword

Description

Currently, every ad that matches a keyword triggers a notification regardless of price. For keywords like "rtx" or "3090", this means users receive alerts for €5 listings (accessories) alongside relevant hardware deals.

This task adds price_min and price_max columns to the keywords table and implements server-side filtering during ad processing. Users set these via bot commands, and ads outside the range are silently skipped without being stored in the ads table or triggering notifications.

Architecture

┌───────────────────────────────────────┐
│  keywords table (extended)            │
│                                       │
│    ...                                 │
│    price_min      int                  │
│    price_max      int                  │
│    ...                                 │
└──────────┬────────────────────────────┘
           │
           ▼
┌───────────────────────────────────────┐
│  Processing pipeline (in main.py)     │
│                                       │
│  for ad in ads_raw:                   │
│    price = _extract_price(ad)         │
│    if price is None:                  │
│      continue                         │
│    if kw.price_min and price < min:   │
│      skip (below minimum)             │
│    if kw.price_max and price > max:   │
│      skip (above maximum)             │
│    → process ad normally              │
└──────────┬────────────────────────────┘
           │
           ▼
┌───────────────────────────────────────┐
│  Bot commands (in bot.py):            │
│                                       │
│  /price_min <keyword> <€amount>       │
│  /price_max <keyword> <€amount>       │
│  /clear_price <keyword>              │
│  /keywords → shows price ranges       │
└───────────────────────────────────────┘

Key design decisions

  • Filter during processing, not at DB time — the filter is applied in Python before inserting into ads. This keeps the ads table clean (only relevant ads are stored) and avoids extra WHERE clauses on every scrape cycle.
    • Alternative: Could use a computed column or trigger, but adds complexity for simple numeric comparison.
  • Both filters optional — NULL = no limit. Users can set only min, only max, both, or neither.
  • Price extraction from ad data: Use the existing _extract_price() function in notifier.py. If price is not available (free items), treat as €0 for filtering purposes.

Implementation Details

1. Add migration

In worker/src/migrations/03-keyword-filters.sql:

ALTER TABLE keywords 
    ADD COLUMN IF NOT EXISTS price_min int,
    ADD COLUMN IF NOT EXISTS price_max int;

COMMENT ON COLUMN keywords.price_min IS 'Minimum price in cents (e.g. 5000 = €50). NULL = no limit.';
COMMENT ON COLUMN keywords.price_max IS 'Maximum price in cents (e.g. 500000 = €5000). NULL = no limit.';

2. Update main.py — filter ads by price during processing

In the scheduler's keyword loop, before calling _process_ad():

# After extracting ad info in _process_ad():
async def _check_price_filters(
    ad_dict: dict, 
    kw_row: dict  # from keywords table
) -> bool:
    """Return True if the ad passes price filters for this keyword."""
    
    from notifier import _extract_price
    
    price = _extract_price(ad_dict)
    
    if price is None:
        # No price found — include it (could be "free" or missing data)
        return True
    
    price_min = kw_row.get("price_min")
    price_max = kw_row.get("price_max")
    
    if price_min is not None and price < price_min:
        logger.debug(
            "Price filter skip: ad %s price=%d min=%d", 
            ad_id, price, price_min
        )
        return False
    
    if price_max is not None and price > price_max:
        logger.debug(
            "Price filter skip: ad %s price=%d max=%d", 
            ad_id, price, price_max
        )
        return False
    
    return True


# In the scheduler loop:
for kw_row in keywords:
    if not kw_row["is_active"]:
        continue
    
    ads_raw = await fetch_ads(kw_row["keyword"])
    
    for ad_dict in ads_raw:
        # Check price filters BEFORE processing
        if not await _check_price_filters(ad_dict, kw_row):
            continue
        
        # Existing _process_ad logic continues here...

3. Add bot commands in bot.py

from telegram import Message
from telegram.ext import ContextTypes


async def cmd_set_price_min(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Set minimum price for a keyword."""
    if len(context.args) < 2:
        await update.message.reply_text("Usage: /price_min <keyword> <amount_in_euro>")
        return
    
    kw_name = context.args[0]
    try:
        amount_eur = float(context.args[1])
        price_cents = int(amount_eur * 100)
    except ValueError:
        await update.message.reply_text("Invalid amount. Use a number like 50 or 99.99")
        return
    
    telegram_id = str(update.effective_user.id)
    
    from db import get_pool
    pool = await get_pool()
    
    # Find keyword by name for this user
    kw_id = await pool.fetchval(
        """SELECT id FROM keywords 
           WHERE LOWER(keyword_name) = $1 AND telegram_id = $2""",
        kw_name.lower(), telegram_id,
    )
    
    if not kw_id:
        await update.message.reply_text(f"Keyword '{kw_name}' not found for your account.")
        return
    
    await pool.execute(
        "UPDATE keywords SET price_min = $1 WHERE id = $2",
        price_cents, kw_id,
    )
    
    await update.message.reply_text(
        f"✅ Keyword '{kw_name}': minimum price set to €{amount_eur:.2f}"
    )


async def cmd_set_price_max(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Set maximum price for a keyword."""
    # Same pattern as cmd_set_price_min but sets price_max
    
    if len(context.args) < 2:
        await update.message.reply_text("Usage: /price_max <keyword> <amount_in_euro>")
        return
    
    kw_name = context.args[0]
    try:
        amount_eur = float(context.args[1])
        price_cents = int(amount_eur * 100)
    except ValueError:
        await update.message.reply_text("Invalid amount. Use a number like 50 or 99.99")
        return
    
    telegram_id = str(update.effective_user.id)
    
    from db import get_pool
    pool = await get_pool()
    
    kw_id = await pool.fetchval(
        """SELECT id FROM keywords 
           WHERE LOWER(keyword_name) = $1 AND telegram_id = $2""",
        kw_name.lower(), telegram_id,
    )
    
    if not kw_id:
        await update.message.reply_text(f"Keyword '{kw_name}' not found.")
        return
    
    await pool.execute(
        "UPDATE keywords SET price_max = $1 WHERE id = $2",
        price_cents, kw_id,
    )
    
    await update.message.reply_text(
        f"✅ Keyword '{kw_name}': maximum price set to €{amount_eur:.2f}"
    )


async def cmd_clear_price(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Clear price filters for a keyword."""
    if len(context.args) < 1:
        await update.message.reply_text("Usage: /clear_price <keyword>")
        return
    
    # ... similar pattern, set both to NULL

Register handlers in register_handlers():

dp.add_handler(MessageHandler(
    REGEX(r"^/price_min"), cmd_set_price_min))
dp.add_handler(MessageHandler(
    REGEX(r"^/price_max"), cmd_set_price_max))
dp.add_handler(MessageHandler(
    REGEX(r"^/clear_price"), cmd_clear_price))

4. Update /keywords command output to show price ranges

In the existing /keywords handler in bot.py, add:

price_info = ""
if row["price_min"] is not None:
    price_info += f"min €{row['price_min']/100:.2f} "
if row["price_max"] is not None:
    price_info += f"max €{row['price_max']/100:.2f}"
if price_info:
    line += f"\n         💰 {price_info.strip()}"

Acceptance Criteria

  • /price_min keyword 50 sets the minimum price to €50.00 (stored as 5000 cents) for that keyword
  • /price_max keyword 1000 sets the maximum price to €1000.00
  • Ads with price below price_min are skipped during processing and not inserted into ads table
  • Ads with price above price_max are skipped during processing
  • /clear_price keyword removes both limits (sets to NULL)
  • Keywords without price filters continue to work as before (no regression)
  • The /keywords command displays the active price range for each keyword
  • Non-admin users can only modify their own keywords' price filters