From 1def72f1859879c0fc7a8f6ffd80e88ecb21f115 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Wed, 10 Dec 2025 13:09:37 +0100 Subject: [PATCH] feat: Add Sniper Alert auto-matching to auction scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCHEDULER ENHANCEMENT: - After each hourly auction scrape, automatically match new auctions against all active Sniper Alerts - _auction_matches_alert() checks all filter criteria: - Keyword matching - TLD whitelist - Min/max length - Min/max price - Exclude numbers - Exclude hyphens - Exclude specific characters - Creates SniperAlertMatch records for dashboard display - Sends email notifications to users with matching alerts - Updates alert's last_triggered timestamp This implements the full Sniper Alert workflow from analysis_3.md: 'Der User kann extrem spezifische Filter speichern. Wenn die Mail kommt, weiß der User: Das ist relevant.' --- backend/app/scheduler.py | 168 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index c6d7783..7c66c4b 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -1,11 +1,12 @@ -"""Background scheduler for domain checks, TLD price scraping, and notifications.""" +"""Background scheduler for domain checks, TLD price scraping, auctions, and notifications.""" import asyncio import logging -from datetime import datetime +from datetime import datetime, timedelta +from typing import TYPE_CHECKING from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger -from sqlalchemy import select +from sqlalchemy import select, and_ from app.config import get_settings from app.database import AsyncSessionLocal @@ -16,6 +17,10 @@ from app.services.domain_checker import domain_checker from app.services.email_service import email_service from app.services.price_tracker import price_tracker +if TYPE_CHECKING: + from app.models.sniper_alert import SniperAlert + from app.models.auction import DomainAuction + logger = logging.getLogger(__name__) settings = get_settings() @@ -315,7 +320,164 @@ async def scrape_auctions(): if result.get('errors'): logger.warning(f"Scrape errors: {result['errors']}") + + # Match new auctions against Sniper Alerts + if result['total_new'] > 0: + await match_sniper_alerts() except Exception as e: logger.exception(f"Auction scrape failed: {e}") + +async def match_sniper_alerts(): + """Match active sniper alerts against current auctions and notify users.""" + from app.models.sniper_alert import SniperAlert, SniperAlertMatch + from app.models.auction import DomainAuction + + logger.info("Matching sniper alerts against new auctions...") + + try: + async with AsyncSessionLocal() as db: + # Get all active sniper alerts + alerts_result = await db.execute( + select(SniperAlert).where(SniperAlert.is_active == True) + ) + alerts = alerts_result.scalars().all() + + if not alerts: + logger.info("No active sniper alerts to match") + return + + # Get recent auctions (added in last 2 hours) + cutoff = datetime.utcnow() - timedelta(hours=2) + auctions_result = await db.execute( + select(DomainAuction).where( + and_( + DomainAuction.is_active == True, + DomainAuction.scraped_at >= cutoff, + ) + ) + ) + auctions = auctions_result.scalars().all() + + if not auctions: + logger.info("No recent auctions to match against") + return + + matches_created = 0 + notifications_sent = 0 + + for alert in alerts: + matching_auctions = [] + + for auction in auctions: + if _auction_matches_alert(auction, alert): + matching_auctions.append(auction) + + if matching_auctions: + for auction in matching_auctions: + # Check if this match already exists + existing = await db.execute( + select(SniperAlertMatch).where( + and_( + SniperAlertMatch.alert_id == alert.id, + SniperAlertMatch.domain == auction.domain, + ) + ) + ) + if existing.scalar_one_or_none(): + continue + + # Create new match + match = SniperAlertMatch( + alert_id=alert.id, + domain=auction.domain, + platform=auction.platform, + current_bid=auction.current_bid, + end_time=auction.end_time, + auction_url=auction.auction_url, + matched_at=datetime.utcnow(), + ) + db.add(match) + matches_created += 1 + + # Update alert last_triggered + alert.last_triggered = datetime.utcnow() + + # Send notification if enabled + if alert.notify_email: + try: + user_result = await db.execute( + select(User).where(User.id == alert.user_id) + ) + user = user_result.scalar_one_or_none() + + if user and email_service.is_enabled: + # Send email with matching domains + domains_list = ", ".join([a.domain for a in matching_auctions[:5]]) + await email_service.send_email( + to_email=user.email, + subject=f"🎯 Sniper Alert: {len(matching_auctions)} matching domains found!", + html_content=f""" +

Your Sniper Alert "{alert.name}" matched!

+

We found {len(matching_auctions)} domains matching your criteria:

+ +

View all matches in your Command Center

+ """ + ) + notifications_sent += 1 + except Exception as e: + logger.error(f"Failed to send sniper alert notification: {e}") + + await db.commit() + logger.info(f"Sniper alert matching complete: {matches_created} matches created, {notifications_sent} notifications sent") + + except Exception as e: + logger.exception(f"Sniper alert matching failed: {e}") + + +def _auction_matches_alert(auction: "DomainAuction", alert: "SniperAlert") -> bool: + """Check if an auction matches the criteria of a sniper alert.""" + domain_name = auction.domain.rsplit('.', 1)[0] if '.' in auction.domain else auction.domain + + # Check keyword filter + if alert.keyword: + if alert.keyword.lower() not in domain_name.lower(): + return False + + # Check TLD filter + if alert.tlds: + allowed_tlds = [t.strip().lower() for t in alert.tlds.split(',')] + if auction.tld.lower() not in allowed_tlds: + return False + + # Check length filters + if alert.min_length and len(domain_name) < alert.min_length: + return False + if alert.max_length and len(domain_name) > alert.max_length: + return False + + # Check price filters + if alert.min_price and auction.current_bid < alert.min_price: + return False + if alert.max_price and auction.current_bid > alert.max_price: + return False + + # Check exclusion filters + if alert.exclude_numbers: + if any(c.isdigit() for c in domain_name): + return False + + if alert.exclude_hyphens: + if '-' in domain_name: + return False + + if alert.exclude_chars: + excluded = set(alert.exclude_chars.lower()) + if any(c in excluded for c in domain_name.lower()): + return False + + return True +