feat: Add Sniper Alert auto-matching to auction scraper

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.'
This commit is contained in:
yves.gugger
2025-12-10 13:09:37 +01:00
parent 76d1d88abc
commit 1def72f185

View File

@ -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"""
<h2>Your Sniper Alert "{alert.name}" matched!</h2>
<p>We found {len(matching_auctions)} domains matching your criteria:</p>
<ul>
{"".join(f"<li><strong>{a.domain}</strong> - ${a.current_bid:.0f} on {a.platform}</li>" for a in matching_auctions[:10])}
</ul>
<p><a href="https://pounce.ch/command/alerts">View all matches in your Command Center</a></p>
"""
)
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