9.1 KiB
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 theadstable 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 innotifier.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 50sets the minimum price to €50.00 (stored as 5000 cents) for that keyword/price_max keyword 1000sets the maximum price to €1000.00- Ads with price below
price_minare skipped during processing and not inserted intoadstable - Ads with price above
price_maxare skipped during processing /clear_price keywordremoves both limits (sets to NULL)- Keywords without price filters continue to work as before (no regression)
- The
/keywordscommand displays the active price range for each keyword - Non-admin users can only modify their own keywords' price filters