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 asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import AsyncSessionLocal
|
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.email_service import email_service
|
||||||
from app.services.price_tracker import price_tracker
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@ -315,7 +320,164 @@ async def scrape_auctions():
|
|||||||
|
|
||||||
if result.get('errors'):
|
if result.get('errors'):
|
||||||
logger.warning(f"Scrape errors: {result['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:
|
except Exception as e:
|
||||||
logger.exception(f"Auction scrape failed: {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