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:
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user