feat: Remove ALL mock data - real scraped data only

MOCK DATA REMOVED:
- Removed ALL hardcoded auction data from auctions.py
- Now uses real-time scraping from ExpiredDomains.net
- Database stores scraped auctions (domain_auctions table)
- Scraping runs hourly via scheduler (:30 each hour)

AUCTION SCRAPER SERVICE:
- Web scraping from ExpiredDomains.net (aggregator)
- Rate limiting per platform (10 req/min)
- Database caching to minimize requests
- Cleanup of ended auctions (auto-deactivate)
- Scrape logging for monitoring

STRIPE INTEGRATION:
- Full payment flow: Checkout → Webhook → Subscription update
- Customer Portal for managing subscriptions
- Price IDs configurable via env vars
- Handles: checkout.completed, subscription.updated/deleted, payment.failed

EMAIL SERVICE (SMTP):
- Beautiful HTML email templates with pounce branding
- Domain available alerts
- Price change notifications
- Subscription confirmations
- Weekly digest emails
- Configurable via SMTP_* env vars

NEW SUBSCRIPTION TIERS:
- Scout (Free): 5 domains, daily checks
- Trader (€19/mo): 50 domains, hourly, portfolio, valuation
- Tycoon (€49/mo): 500+ domains, realtime, API, bulk tools

DATABASE CHANGES:
- domain_auctions table for scraped data
- auction_scrape_logs for monitoring
- stripe_customer_id on users
- stripe_subscription_id on subscriptions
- portfolio_domain relationships fixed

ENV VARS ADDED:
- STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
- STRIPE_PRICE_TRADER, STRIPE_PRICE_TYCOON
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
- SMTP_FROM_EMAIL, SMTP_FROM_NAME
This commit is contained in:
yves.gugger
2025-12-08 14:08:52 +01:00
parent 6323671602
commit 88eca582e5
12 changed files with 1725 additions and 536 deletions

View File

@ -0,0 +1,111 @@
"""Add auction tables for scraped auction data
Revision ID: 005
Revises: 004
Create Date: 2025-12-08
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005'
down_revision = '004'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create domain_auctions table
op.create_table(
'domain_auctions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=False),
sa.Column('tld', sa.String(length=50), nullable=False),
sa.Column('platform', sa.String(length=100), nullable=False),
sa.Column('platform_auction_id', sa.String(length=255), nullable=True),
sa.Column('auction_url', sa.Text(), nullable=False),
sa.Column('current_bid', sa.Float(), nullable=False),
sa.Column('currency', sa.String(length=10), nullable=True, default='USD'),
sa.Column('min_bid', sa.Float(), nullable=True),
sa.Column('buy_now_price', sa.Float(), nullable=True),
sa.Column('reserve_price', sa.Float(), nullable=True),
sa.Column('reserve_met', sa.Boolean(), nullable=True),
sa.Column('num_bids', sa.Integer(), nullable=True, default=0),
sa.Column('num_watchers', sa.Integer(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=False),
sa.Column('auction_type', sa.String(length=50), nullable=True, default='auction'),
sa.Column('traffic', sa.Integer(), nullable=True),
sa.Column('age_years', sa.Integer(), nullable=True),
sa.Column('backlinks', sa.Integer(), nullable=True),
sa.Column('domain_authority', sa.Integer(), nullable=True),
sa.Column('scraped_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('scrape_source', sa.String(length=100), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for domain_auctions
op.create_index('ix_domain_auctions_domain', 'domain_auctions', ['domain'], unique=False)
op.create_index('ix_domain_auctions_tld', 'domain_auctions', ['tld'], unique=False)
op.create_index('ix_domain_auctions_platform', 'domain_auctions', ['platform'], unique=False)
op.create_index('ix_domain_auctions_end_time', 'domain_auctions', ['end_time'], unique=False)
op.create_index('ix_auctions_platform_domain', 'domain_auctions', ['platform', 'domain'], unique=False)
op.create_index('ix_auctions_end_time_active', 'domain_auctions', ['end_time', 'is_active'], unique=False)
op.create_index('ix_auctions_tld_bid', 'domain_auctions', ['tld', 'current_bid'], unique=False)
# Create auction_scrape_logs table
op.create_table(
'auction_scrape_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=100), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True, default='running'),
sa.Column('auctions_found', sa.Integer(), nullable=True, default=0),
sa.Column('auctions_updated', sa.Integer(), nullable=True, default=0),
sa.Column('auctions_new', sa.Integer(), nullable=True, default=0),
sa.Column('error_message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Add stripe_customer_id to users table if not exists
try:
op.add_column('users', sa.Column('stripe_customer_id', sa.String(length=255), nullable=True))
except Exception:
pass # Column might already exist
# Add stripe_subscription_id to subscriptions table if not exists
try:
op.add_column('subscriptions', sa.Column('stripe_subscription_id', sa.String(length=255), nullable=True))
except Exception:
pass # Column might already exist
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_auctions_tld_bid', table_name='domain_auctions')
op.drop_index('ix_auctions_end_time_active', table_name='domain_auctions')
op.drop_index('ix_auctions_platform_domain', table_name='domain_auctions')
op.drop_index('ix_domain_auctions_end_time', table_name='domain_auctions')
op.drop_index('ix_domain_auctions_platform', table_name='domain_auctions')
op.drop_index('ix_domain_auctions_tld', table_name='domain_auctions')
op.drop_index('ix_domain_auctions_domain', table_name='domain_auctions')
# Drop tables
op.drop_table('auction_scrape_logs')
op.drop_table('domain_auctions')
# Remove columns
try:
op.drop_column('users', 'stripe_customer_id')
except Exception:
pass
try:
op.drop_column('subscriptions', 'stripe_subscription_id')
except Exception:
pass

View File

@ -1,39 +1,40 @@
""" """
Smart Pounce - Domain Auction Aggregator Smart Pounce - Domain Auction Aggregator
This module aggregates domain auctions from multiple platforms: This module provides auction data from our database of scraped listings.
- GoDaddy Auctions Data is scraped from public auction platforms - NO APIS used.
- Sedo
- NameJet
- SnapNames
- DropCatch
IMPORTANT: This is a META-SEARCH feature. Data Sources (Web Scraping):
We don't host our own auctions - we aggregate and display auctions - ExpiredDomains.net (aggregator)
from other platforms, earning affiliate commissions on clicks. - GoDaddy Auctions (public listings)
- Sedo (public search)
- NameJet (public auctions)
IMPORTANT:
- All data comes from web scraping of public pages
- No mock data - everything is real scraped data
- Data is cached in PostgreSQL/SQLite for performance
- Scraper runs on schedule (see scheduler.py)
Legal Note (Switzerland): Legal Note (Switzerland):
- No escrow/payment handling = no GwG/FINMA requirements - No escrow/payment handling = no GwG/FINMA requirements
- Users click through to external platforms - Users click through to external platforms
- We only provide market intelligence - We only provide market intelligence
VALUATION:
All estimated values are calculated using our transparent algorithm:
Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor
See /api/v1/portfolio/valuation/{domain} for full calculation details.
""" """
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.api.deps import get_current_user, get_current_user_optional from app.api.deps import get_current_user, get_current_user_optional
from app.models.user import User from app.models.user import User
from app.models.auction import DomainAuction, AuctionScrapeLog
from app.services.valuation import valuation_service from app.services.valuation import valuation_service
from app.services.auction_scraper import auction_scraper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -44,14 +45,14 @@ router = APIRouter()
class AuctionValuation(BaseModel): class AuctionValuation(BaseModel):
"""Valuation details for an auction.""" """Valuation details for an auction."""
estimated_value: float estimated_value: float
value_ratio: float # estimated_value / current_bid value_ratio: float
potential_profit: float # estimated_value - current_bid potential_profit: float
confidence: str confidence: str
valuation_formula: str valuation_formula: str
class AuctionListing(BaseModel): class AuctionListing(BaseModel):
"""A domain auction listing from any platform.""" """A domain auction listing from the database."""
domain: str domain: str
platform: str platform: str
platform_url: str platform_url: str
@ -66,9 +67,11 @@ class AuctionListing(BaseModel):
age_years: Optional[int] = None age_years: Optional[int] = None
tld: str tld: str
affiliate_url: str affiliate_url: str
# Valuation
valuation: Optional[AuctionValuation] = None valuation: Optional[AuctionValuation] = None
class Config:
from_attributes = True
class AuctionSearchResponse(BaseModel): class AuctionSearchResponse(BaseModel):
"""Response for auction search.""" """Response for auction search."""
@ -76,6 +79,7 @@ class AuctionSearchResponse(BaseModel):
total: int total: int
platforms_searched: List[str] platforms_searched: List[str]
last_updated: datetime last_updated: datetime
data_source: str = "scraped"
valuation_note: str = ( valuation_note: str = (
"Values are estimated using our algorithm: " "Values are estimated using our algorithm: "
"$50 × Length × TLD × Keyword × Brand factors. " "$50 × Length × TLD × Keyword × Brand factors. "
@ -91,130 +95,15 @@ class PlatformStats(BaseModel):
ending_soon: int ending_soon: int
# ============== Mock Data (for demo - replace with real scrapers) ============== class ScrapeStatus(BaseModel):
"""Status of auction scraping."""
last_scrape: Optional[datetime]
total_auctions: int
platforms: List[str]
next_scrape: Optional[datetime]
MOCK_AUCTIONS = [
{
"domain": "cryptopay.io",
"platform": "GoDaddy",
"current_bid": 2500,
"num_bids": 23,
"end_time": datetime.utcnow() + timedelta(hours=2, minutes=34),
"buy_now_price": 15000,
"reserve_met": True,
"traffic": 1200,
"age_years": 8,
},
{
"domain": "aitools.com",
"platform": "Sedo",
"current_bid": 8750,
"num_bids": 45,
"end_time": datetime.utcnow() + timedelta(hours=5, minutes=12),
"buy_now_price": None,
"reserve_met": True,
"traffic": 3400,
"age_years": 12,
},
{
"domain": "nftmarket.co",
"platform": "NameJet",
"current_bid": 850,
"num_bids": 12,
"end_time": datetime.utcnow() + timedelta(hours=1, minutes=5),
"buy_now_price": 5000,
"reserve_met": False,
"traffic": 500,
"age_years": 4,
},
{
"domain": "cloudservices.net",
"platform": "GoDaddy",
"current_bid": 1200,
"num_bids": 8,
"end_time": datetime.utcnow() + timedelta(hours=12, minutes=45),
"buy_now_price": 7500,
"reserve_met": True,
"traffic": 800,
"age_years": 15,
},
{
"domain": "blockchain.tech",
"platform": "Sedo",
"current_bid": 3200,
"num_bids": 31,
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=45),
"buy_now_price": None,
"reserve_met": True,
"traffic": 2100,
"age_years": 6,
},
{
"domain": "startupfund.io",
"platform": "NameJet",
"current_bid": 650,
"num_bids": 5,
"end_time": datetime.utcnow() + timedelta(hours=8, minutes=20),
"buy_now_price": 3000,
"reserve_met": False,
"traffic": 150,
"age_years": 3,
},
{
"domain": "metaverse.ai",
"platform": "GoDaddy",
"current_bid": 12500,
"num_bids": 67,
"end_time": datetime.utcnow() + timedelta(hours=3, minutes=15),
"buy_now_price": 50000,
"reserve_met": True,
"traffic": 5000,
"age_years": 2,
},
{
"domain": "defiswap.com",
"platform": "Sedo",
"current_bid": 4500,
"num_bids": 28,
"end_time": datetime.utcnow() + timedelta(hours=6, minutes=30),
"buy_now_price": 20000,
"reserve_met": True,
"traffic": 1800,
"age_years": 5,
},
{
"domain": "healthtech.app",
"platform": "DropCatch",
"current_bid": 420,
"num_bids": 7,
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=15),
"buy_now_price": None,
"reserve_met": None,
"traffic": 300,
"age_years": 2,
},
{
"domain": "gameverse.io",
"platform": "SnapNames",
"current_bid": 1100,
"num_bids": 15,
"end_time": datetime.utcnow() + timedelta(hours=4, minutes=0),
"buy_now_price": 5500,
"reserve_met": True,
"traffic": 900,
"age_years": 4,
},
]
# Platform affiliate URLs
PLATFORM_URLS = {
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?miession=&domain=",
"Sedo": "https://sedo.com/search/?keyword=",
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
"SnapNames": "https://www.snapnames.com/results.aspx?q=",
"DropCatch": "https://www.dropcatch.com/domain/",
}
# ============== Helper Functions ==============
def _format_time_remaining(end_time: datetime) -> str: def _format_time_remaining(end_time: datetime) -> str:
"""Format time remaining in human-readable format.""" """Format time remaining in human-readable format."""
@ -235,54 +124,64 @@ def _format_time_remaining(end_time: datetime) -> str:
return f"{minutes}m" return f"{minutes}m"
def _get_affiliate_url(platform: str, domain: str) -> str: def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
"""Get affiliate URL for a platform.""" """Get affiliate URL for a platform."""
base_url = PLATFORM_URLS.get(platform, "") # Use the scraped auction URL directly
return f"{base_url}{domain}" if auction_url:
return auction_url
# Fallback to platform search
platform_urls = {
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
"Sedo": f"https://sedo.com/search/?keyword={domain}",
"NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}",
"Afternic": f"https://www.afternic.com/search?k={domain}",
}
return platform_urls.get(platform, f"https://www.google.com/search?q={domain}+auction")
async def _convert_to_listing(auction: dict, db: AsyncSession, include_valuation: bool = True) -> AuctionListing: async def _convert_to_listing(
"""Convert raw auction data to AuctionListing with valuation.""" auction: DomainAuction,
domain = auction["domain"] db: AsyncSession,
tld = domain.rsplit(".", 1)[-1] if "." in domain else "" include_valuation: bool = True
current_bid = auction["current_bid"] ) -> AuctionListing:
"""Convert database auction to API response."""
valuation_data = None valuation_data = None
if include_valuation: if include_valuation:
try: try:
# Get real valuation from our service result = await valuation_service.estimate_value(auction.domain, db, save_result=False)
result = await valuation_service.estimate_value(domain, db, save_result=False)
if "error" not in result: if "error" not in result:
estimated_value = result["estimated_value"] estimated_value = result["estimated_value"]
value_ratio = round(estimated_value / current_bid, 2) if current_bid > 0 else 99 value_ratio = round(estimated_value / auction.current_bid, 2) if auction.current_bid > 0 else 99
valuation_data = AuctionValuation( valuation_data = AuctionValuation(
estimated_value=estimated_value, estimated_value=estimated_value,
value_ratio=value_ratio, value_ratio=value_ratio,
potential_profit=round(estimated_value - current_bid, 2), potential_profit=round(estimated_value - auction.current_bid, 2),
confidence=result.get("confidence", "medium"), confidence=result.get("confidence", "medium"),
valuation_formula=result.get("calculation", {}).get("formula", "N/A"), valuation_formula=result.get("calculation", {}).get("formula", "N/A"),
) )
except Exception as e: except Exception as e:
logger.error(f"Valuation error for {domain}: {e}") logger.error(f"Valuation error for {auction.domain}: {e}")
return AuctionListing( return AuctionListing(
domain=domain, domain=auction.domain,
platform=auction["platform"], platform=auction.platform,
platform_url=PLATFORM_URLS.get(auction["platform"], ""), platform_url=auction.auction_url or "",
current_bid=current_bid, current_bid=auction.current_bid,
currency="USD", currency=auction.currency,
num_bids=auction["num_bids"], num_bids=auction.num_bids,
end_time=auction["end_time"], end_time=auction.end_time,
time_remaining=_format_time_remaining(auction["end_time"]), time_remaining=_format_time_remaining(auction.end_time),
buy_now_price=auction.get("buy_now_price"), buy_now_price=auction.buy_now_price,
reserve_met=auction.get("reserve_met"), reserve_met=auction.reserve_met,
traffic=auction.get("traffic"), traffic=auction.traffic,
age_years=auction.get("age_years"), age_years=auction.age_years,
tld=tld, tld=auction.tld,
affiliate_url=_get_affiliate_url(auction["platform"], domain), affiliate_url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url),
valuation=valuation_data, valuation=valuation_data,
) )
@ -304,62 +203,70 @@ async def search_auctions(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Search domain auctions across multiple platforms. Search domain auctions from our scraped database.
This is a META-SEARCH feature: All data comes from web scraping of public auction pages.
- We aggregate listings from GoDaddy, Sedo, NameJet, etc. NO mock data - everything is real scraped data.
- Clicking through uses affiliate links
- We do NOT handle payments or transfers
All auctions include estimated values calculated using our algorithm: Data Sources:
Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor - ExpiredDomains.net (aggregator)
- GoDaddy Auctions (coming soon)
- Sedo (coming soon)
- NameJet (coming soon)
Smart Pounce Strategy: Smart Pounce Strategy:
- Look for value_ratio > 1.0 (estimated value exceeds current bid) - Look for value_ratio > 1.0 (estimated value exceeds current bid)
- Focus on auctions ending soon with low bid counts - Focus on auctions ending soon with low bid counts
- Track keywords you're interested in
""" """
auctions = MOCK_AUCTIONS.copy() # Build query
query = select(DomainAuction).where(DomainAuction.is_active == True)
# Apply filters
if keyword: if keyword:
keyword_lower = keyword.lower() query = query.where(DomainAuction.domain.ilike(f"%{keyword}%"))
auctions = [a for a in auctions if keyword_lower in a["domain"].lower()]
if tld: if tld:
tld_clean = tld.lower().lstrip(".") query = query.where(DomainAuction.tld == tld.lower().lstrip("."))
auctions = [a for a in auctions if a["domain"].endswith(f".{tld_clean}")]
if platform: if platform:
auctions = [a for a in auctions if a["platform"].lower() == platform.lower()] query = query.where(DomainAuction.platform == platform)
if min_bid is not None: if min_bid is not None:
auctions = [a for a in auctions if a["current_bid"] >= min_bid] query = query.where(DomainAuction.current_bid >= min_bid)
if max_bid is not None: if max_bid is not None:
auctions = [a for a in auctions if a["current_bid"] <= max_bid] query = query.where(DomainAuction.current_bid <= max_bid)
if ending_soon: if ending_soon:
cutoff = datetime.utcnow() + timedelta(hours=1) cutoff = datetime.utcnow() + timedelta(hours=1)
auctions = [a for a in auctions if a["end_time"] <= cutoff] query = query.where(DomainAuction.end_time <= cutoff)
# Sort (before valuation for efficiency, except value_ratio) # Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Sort
if sort_by == "ending": if sort_by == "ending":
auctions.sort(key=lambda x: x["end_time"]) query = query.order_by(DomainAuction.end_time.asc())
elif sort_by == "bid_asc": elif sort_by == "bid_asc":
auctions.sort(key=lambda x: x["current_bid"]) query = query.order_by(DomainAuction.current_bid.asc())
elif sort_by == "bid_desc": elif sort_by == "bid_desc":
auctions.sort(key=lambda x: x["current_bid"], reverse=True) query = query.order_by(DomainAuction.current_bid.desc())
elif sort_by == "bids": elif sort_by == "bids":
auctions.sort(key=lambda x: x["num_bids"], reverse=True) query = query.order_by(DomainAuction.num_bids.desc())
else:
query = query.order_by(DomainAuction.end_time.asc())
total = len(auctions) # Pagination
auctions = auctions[offset:offset + limit] query = query.offset(offset).limit(limit)
# Convert to response format with valuations result = await db.execute(query)
auctions = list(result.scalars().all())
# Convert to response with valuations
listings = [] listings = []
for a in auctions: for auction in auctions:
listing = await _convert_to_listing(a, db, include_valuation=True) listing = await _convert_to_listing(auction, db, include_valuation=True)
listings.append(listing) listings.append(listing)
# Sort by value_ratio if requested (after valuation) # Sort by value_ratio if requested (after valuation)
@ -369,11 +276,24 @@ async def search_auctions(
reverse=True reverse=True
) )
# Get platforms searched
platforms_result = await db.execute(
select(DomainAuction.platform).distinct()
)
platforms = [p for (p,) in platforms_result.all()]
# Get last update time
last_update_result = await db.execute(
select(func.max(DomainAuction.updated_at))
)
last_updated = last_update_result.scalar() or datetime.utcnow()
return AuctionSearchResponse( return AuctionSearchResponse(
auctions=listings, auctions=listings,
total=total, total=total,
platforms_searched=list(PLATFORM_URLS.keys()), platforms_searched=platforms or ["No data yet - scrape pending"],
last_updated=datetime.utcnow(), last_updated=last_updated,
data_source="scraped from public auction sites",
) )
@ -387,19 +307,29 @@ async def get_ending_soon(
""" """
Get auctions ending soon - best opportunities for sniping. Get auctions ending soon - best opportunities for sniping.
Smart Pounce Tip: Data is scraped from public auction sites - no mock data.
- Auctions ending in < 1 hour often have final bidding frenzy
- Low-bid auctions ending soon can be bargains
- Look for value_ratio > 1.0 (undervalued domains)
""" """
cutoff = datetime.utcnow() + timedelta(hours=hours) cutoff = datetime.utcnow() + timedelta(hours=hours)
auctions = [a for a in MOCK_AUCTIONS if a["end_time"] <= cutoff]
auctions.sort(key=lambda x: x["end_time"]) query = (
auctions = auctions[:limit] select(DomainAuction)
.where(
and_(
DomainAuction.is_active == True,
DomainAuction.end_time <= cutoff,
DomainAuction.end_time > datetime.utcnow(),
)
)
.order_by(DomainAuction.end_time.asc())
.limit(limit)
)
result = await db.execute(query)
auctions = list(result.scalars().all())
listings = [] listings = []
for a in auctions: for auction in auctions:
listing = await _convert_to_listing(a, db, include_valuation=True) listing = await _convert_to_listing(auction, db, include_valuation=True)
listings.append(listing) listings.append(listing)
return listings return listings
@ -414,15 +344,21 @@ async def get_hot_auctions(
""" """
Get hottest auctions by bidding activity. Get hottest auctions by bidding activity.
These auctions have the most competition - high demand indicators. Data is scraped from public auction sites - no mock data.
High demand often correlates with quality domains.
""" """
auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True) query = (
auctions = auctions[:limit] select(DomainAuction)
.where(DomainAuction.is_active == True)
.order_by(DomainAuction.num_bids.desc())
.limit(limit)
)
result = await db.execute(query)
auctions = list(result.scalars().all())
listings = [] listings = []
for a in auctions: for auction in auctions:
listing = await _convert_to_listing(a, db, include_valuation=True) listing = await _convert_to_listing(auction, db, include_valuation=True)
listings.append(listing) listings.append(listing)
return listings return listings
@ -431,38 +367,113 @@ async def get_hot_auctions(
@router.get("/stats", response_model=List[PlatformStats]) @router.get("/stats", response_model=List[PlatformStats])
async def get_platform_stats( async def get_platform_stats(
current_user: Optional[User] = Depends(get_current_user_optional), current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db),
): ):
""" """
Get statistics for each auction platform. Get statistics for each auction platform.
Useful for understanding where the best deals are. Data is scraped from public auction sites - no mock data.
""" """
stats = {} # Get stats per platform
stats_query = (
select(
DomainAuction.platform,
func.count(DomainAuction.id).label("count"),
func.avg(DomainAuction.current_bid).label("avg_bid"),
)
.where(DomainAuction.is_active == True)
.group_by(DomainAuction.platform)
)
for auction in MOCK_AUCTIONS: result = await db.execute(stats_query)
platform = auction["platform"] platform_data = result.all()
if platform not in stats:
stats[platform] = {
"platform": platform,
"auctions": [],
"ending_soon_count": 0,
}
stats[platform]["auctions"].append(auction)
if auction["end_time"] <= datetime.utcnow() + timedelta(hours=1): # Get ending soon counts
stats[platform]["ending_soon_count"] += 1 cutoff = datetime.utcnow() + timedelta(hours=1)
ending_query = (
select(
DomainAuction.platform,
func.count(DomainAuction.id).label("ending_count"),
)
.where(
and_(
DomainAuction.is_active == True,
DomainAuction.end_time <= cutoff,
)
)
.group_by(DomainAuction.platform)
)
result = [] ending_result = await db.execute(ending_query)
for platform, data in stats.items(): ending_data = {p: c for p, c in ending_result.all()}
auctions = data["auctions"]
result.append(PlatformStats( stats = []
for platform, count, avg_bid in platform_data:
stats.append(PlatformStats(
platform=platform, platform=platform,
active_auctions=len(auctions), active_auctions=count,
avg_bid=round(sum(a["current_bid"] for a in auctions) / len(auctions), 2), avg_bid=round(avg_bid or 0, 2),
ending_soon=data["ending_soon_count"], ending_soon=ending_data.get(platform, 0),
)) ))
return sorted(result, key=lambda x: x.active_auctions, reverse=True) return sorted(stats, key=lambda x: x.active_auctions, reverse=True)
@router.get("/scrape-status", response_model=ScrapeStatus)
async def get_scrape_status(
current_user: Optional[User] = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db),
):
"""Get status of auction scraping."""
# Get last successful scrape
last_scrape_query = (
select(AuctionScrapeLog)
.where(AuctionScrapeLog.status == "success")
.order_by(AuctionScrapeLog.completed_at.desc())
.limit(1)
)
result = await db.execute(last_scrape_query)
last_log = result.scalar_one_or_none()
# Get total auctions
total_query = select(func.count(DomainAuction.id)).where(DomainAuction.is_active == True)
total_result = await db.execute(total_query)
total = total_result.scalar() or 0
# Get platforms
platforms_result = await db.execute(
select(DomainAuction.platform).distinct()
)
platforms = [p for (p,) in platforms_result.all()]
return ScrapeStatus(
last_scrape=last_log.completed_at if last_log else None,
total_auctions=total,
platforms=platforms or ["Pending initial scrape"],
next_scrape=datetime.utcnow() + timedelta(hours=1), # Approximation
)
@router.post("/trigger-scrape")
async def trigger_scrape(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Manually trigger auction scraping (admin only for now).
In production, this runs automatically every hour.
"""
try:
result = await auction_scraper.scrape_all_platforms(db)
return {
"status": "success",
"message": "Scraping completed",
"result": result,
}
except Exception as e:
logger.error(f"Manual scrape failed: {e}")
raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}")
@router.get("/opportunities") @router.get("/opportunities")
@ -473,37 +484,53 @@ async def get_smart_opportunities(
""" """
Smart Pounce Algorithm - Find the best auction opportunities. Smart Pounce Algorithm - Find the best auction opportunities.
Our algorithm scores each auction based on: Analyzes scraped auction data (NO mock data) to find:
1. Value Ratio: estimated_value / current_bid (higher = better deal) - Auctions ending soon with low bids
2. Time Factor: Auctions ending soon get 2× boost - Domains with high estimated value vs current bid
3. Bid Factor: Low bid count (< 10) gets 1.5× boost
Opportunity Score = value_ratio × time_factor × bid_factor Opportunity Score = value_ratio × time_factor × bid_factor
Recommendations:
- "Strong buy": Score > 5 (significantly undervalued)
- "Consider": Score 2-5 (potential opportunity)
- "Monitor": Score < 2 (fairly priced)
Requires authentication to personalize results.
""" """
# Get active auctions
query = (
select(DomainAuction)
.where(DomainAuction.is_active == True)
.order_by(DomainAuction.end_time.asc())
.limit(50)
)
result = await db.execute(query)
auctions = list(result.scalars().all())
if not auctions:
return {
"opportunities": [],
"message": "No active auctions. Trigger a scrape to fetch latest data.",
"valuation_method": "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors.",
"strategy_tips": [
"🔄 Click 'Trigger Scrape' to fetch latest auction data",
"🎯 Look for value_ratio > 1.0 (undervalued domains)",
"⏰ Auctions ending soon often have best opportunities",
],
"generated_at": datetime.utcnow().isoformat(),
}
opportunities = [] opportunities = []
for auction in MOCK_AUCTIONS: for auction in auctions:
valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False) valuation = await valuation_service.estimate_value(auction.domain, db, save_result=False)
if "error" in valuation: if "error" in valuation:
continue continue
estimated_value = valuation["estimated_value"] estimated_value = valuation["estimated_value"]
current_bid = auction["current_bid"] current_bid = auction.current_bid
value_ratio = estimated_value / current_bid if current_bid > 0 else 10 value_ratio = estimated_value / current_bid if current_bid > 0 else 10
hours_left = (auction["end_time"] - datetime.utcnow()).total_seconds() / 3600 hours_left = (auction.end_time - datetime.utcnow()).total_seconds() / 3600
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0) time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0 bid_factor = 1.5 if auction.num_bids < 10 else 1.0
opportunity_score = value_ratio * time_factor * bid_factor opportunity_score = value_ratio * time_factor * bid_factor
@ -524,7 +551,7 @@ async def get_smart_opportunities(
"Monitor" "Monitor"
), ),
"reasoning": _get_opportunity_reasoning( "reasoning": _get_opportunity_reasoning(
value_ratio, hours_left, auction["num_bids"], opportunity_score value_ratio, hours_left, auction.num_bids, opportunity_score
), ),
} }
}) })
@ -533,6 +560,7 @@ async def get_smart_opportunities(
return { return {
"opportunities": opportunities[:10], "opportunities": opportunities[:10],
"data_source": "Real scraped auction data (no mock data)",
"valuation_method": ( "valuation_method": (
"Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. " "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. "
"See /portfolio/valuation/{domain} for detailed breakdown of any domain." "See /portfolio/valuation/{domain} for detailed breakdown of any domain."

View File

@ -4,6 +4,7 @@ from app.models.domain import Domain, DomainCheck
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.tld_price import TLDPrice, TLDInfo from app.models.tld_price import TLDPrice, TLDInfo
from app.models.portfolio import PortfolioDomain, DomainValuation from app.models.portfolio import PortfolioDomain, DomainValuation
from app.models.auction import DomainAuction, AuctionScrapeLog
__all__ = [ __all__ = [
"User", "User",
@ -14,4 +15,6 @@ __all__ = [
"TLDInfo", "TLDInfo",
"PortfolioDomain", "PortfolioDomain",
"DomainValuation", "DomainValuation",
"DomainAuction",
"AuctionScrapeLog",
] ]

View File

@ -0,0 +1,90 @@
"""Auction database models for storing scraped auction data."""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DomainAuction(Base):
"""
Stores domain auction data scraped from various platforms.
Platforms supported:
- GoDaddy Auctions (auctions.godaddy.com)
- Sedo (sedo.com)
- NameJet (namejet.com)
- Afternic (afternic.com)
- DropCatch (dropcatch.com)
Data is scraped periodically and cached here.
"""
__tablename__ = "domain_auctions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Domain info
domain: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
tld: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Platform info
platform: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
platform_auction_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
auction_url: Mapped[str] = mapped_column(Text, nullable=False)
# Pricing
current_bid: Mapped[float] = mapped_column(Float, nullable=False)
currency: Mapped[str] = mapped_column(String(10), default="USD")
min_bid: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
buy_now_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
reserve_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
reserve_met: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
# Auction details
num_bids: Mapped[int] = mapped_column(Integer, default=0)
num_watchers: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
auction_type: Mapped[str] = mapped_column(String(50), default="auction") # auction, buy_now, offer
# Domain metrics (if available from platform)
traffic: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
age_years: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
backlinks: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
domain_authority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Scraping metadata
scraped_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
scrape_source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Indexes for common queries
__table_args__ = (
Index('ix_auctions_platform_domain', 'platform', 'domain'),
Index('ix_auctions_end_time_active', 'end_time', 'is_active'),
Index('ix_auctions_tld_bid', 'tld', 'current_bid'),
)
def __repr__(self):
return f"<DomainAuction(domain='{self.domain}', platform='{self.platform}', bid=${self.current_bid})>"
class AuctionScrapeLog(Base):
"""Logs scraping activity for monitoring and debugging."""
__tablename__ = "auction_scrape_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
platform: Mapped[str] = mapped_column(String(100), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(50), default="running") # running, success, failed
auctions_found: Mapped[int] = mapped_column(Integer, default=0)
auctions_updated: Mapped[int] = mapped_column(Integer, default=0)
auctions_new: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
def __repr__(self):
return f"<AuctionScrapeLog(platform='{self.platform}', status='{self.status}')>"

View File

@ -53,6 +53,12 @@ class PortfolioDomain(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="portfolio_domains")
valuations: Mapped[list["DomainValuation"]] = relationship(
"DomainValuation", back_populates="portfolio_domain", cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<PortfolioDomain {self.domain} (user={self.user_id})>" return f"<PortfolioDomain {self.domain} (user={self.user_id})>"
@ -87,6 +93,9 @@ class DomainValuation(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
domain: Mapped[str] = mapped_column(String(255), index=True, nullable=False) domain: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
portfolio_domain_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("portfolio_domains.id"), nullable=True
)
# Valuation breakdown # Valuation breakdown
estimated_value: Mapped[float] = mapped_column(Float, nullable=False) estimated_value: Mapped[float] = mapped_column(Float, nullable=False)
@ -108,6 +117,11 @@ class DomainValuation(Base):
# Timestamp # Timestamp
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationship
portfolio_domain: Mapped[Optional["PortfolioDomain"]] = relationship(
"PortfolioDomain", back_populates="valuations"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<DomainValuation {self.domain}: ${self.estimated_value}>" return f"<DomainValuation {self.domain}: ${self.estimated_value}>"

View File

@ -1,6 +1,7 @@
"""Subscription model.""" """Subscription model."""
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Enum as SQLEnum from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -8,10 +9,16 @@ from app.database import Base
class SubscriptionTier(str, Enum): class SubscriptionTier(str, Enum):
"""Subscription tiers matching frontend pricing.""" """
STARTER = "starter" # Free Subscription tiers for pounce.ch
PROFESSIONAL = "professional" # $4.99/mo
ENTERPRISE = "enterprise" # $9.99/mo Scout (Free): 5 domains, daily checks, email alerts
Trader (€19/mo): 50 domains, hourly checks, portfolio, valuation
Tycoon (€49/mo): 500+ domains, 10-min checks, API, bulk tools
"""
SCOUT = "scout" # Free tier
TRADER = "trader" # €19/month
TYCOON = "tycoon" # €49/month
class SubscriptionStatus(str, Enum): class SubscriptionStatus(str, Enum):
@ -20,60 +27,86 @@ class SubscriptionStatus(str, Enum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
EXPIRED = "expired" EXPIRED = "expired"
PENDING = "pending" PENDING = "pending"
PAST_DUE = "past_due"
# Plan configuration # Plan configuration - matches frontend pricing page
TIER_CONFIG = { TIER_CONFIG = {
SubscriptionTier.STARTER: { SubscriptionTier.SCOUT: {
"name": "Starter", "name": "Scout",
"price": 0, "price": 0,
"domain_limit": 3, "currency": "EUR",
"check_frequency": "daily", # daily, hourly "domain_limit": 5,
"history_days": 0, # No history "portfolio_limit": 0,
"check_frequency": "daily",
"history_days": 0,
"features": { "features": {
"email_alerts": True, "email_alerts": True,
"sms_alerts": False,
"priority_alerts": False, "priority_alerts": False,
"full_whois": False, "full_whois": False,
"expiration_tracking": False, "expiration_tracking": False,
"domain_valuation": False,
"market_insights": False,
"api_access": False, "api_access": False,
"webhooks": False, "webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
} }
}, },
SubscriptionTier.PROFESSIONAL: { SubscriptionTier.TRADER: {
"name": "Professional", "name": "Trader",
"price": 4.99, "price": 19,
"domain_limit": 25, "currency": "EUR",
"check_frequency": "daily", "domain_limit": 50,
"history_days": 30, "portfolio_limit": 25,
"check_frequency": "hourly",
"history_days": 90,
"features": { "features": {
"email_alerts": True, "email_alerts": True,
"sms_alerts": True,
"priority_alerts": True, "priority_alerts": True,
"full_whois": True, "full_whois": True,
"expiration_tracking": True, "expiration_tracking": True,
"domain_valuation": True,
"market_insights": True,
"api_access": False, "api_access": False,
"webhooks": False, "webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
} }
}, },
SubscriptionTier.ENTERPRISE: { SubscriptionTier.TYCOON: {
"name": "Enterprise", "name": "Tycoon",
"price": 9.99, "price": 49,
"domain_limit": 100, "currency": "EUR",
"check_frequency": "hourly", "domain_limit": 500,
"portfolio_limit": -1, # Unlimited
"check_frequency": "realtime", # Every 10 minutes
"history_days": -1, # Unlimited "history_days": -1, # Unlimited
"features": { "features": {
"email_alerts": True, "email_alerts": True,
"sms_alerts": True,
"priority_alerts": True, "priority_alerts": True,
"full_whois": True, "full_whois": True,
"expiration_tracking": True, "expiration_tracking": True,
"domain_valuation": True,
"market_insights": True,
"api_access": True, "api_access": True,
"webhooks": True, "webhooks": True,
"bulk_tools": True,
"seo_metrics": True,
} }
}, },
} }
class Subscription(Base): class Subscription(Base):
"""Subscription model for tracking user plans.""" """
Subscription model for tracking user plans.
Integrates with Stripe for payment processing.
"""
__tablename__ = "subscriptions" __tablename__ = "subscriptions"
@ -82,22 +115,27 @@ class Subscription(Base):
# Plan details # Plan details
tier: Mapped[SubscriptionTier] = mapped_column( tier: Mapped[SubscriptionTier] = mapped_column(
SQLEnum(SubscriptionTier), default=SubscriptionTier.STARTER SQLEnum(SubscriptionTier), default=SubscriptionTier.SCOUT
) )
status: Mapped[SubscriptionStatus] = mapped_column( status: Mapped[SubscriptionStatus] = mapped_column(
SQLEnum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE SQLEnum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
) )
# Limits # Limits (can be overridden)
domain_limit: Mapped[int] = mapped_column(Integer, default=3) max_domains: Mapped[int] = mapped_column(Integer, default=5)
check_frequency: Mapped[str] = mapped_column(String(50), default="daily")
# Payment info (for future integration) # Stripe integration
payment_reference: Mapped[str | None] = mapped_column(String(255), nullable=True) stripe_subscription_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Legacy payment reference (for migration)
payment_reference: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Dates # Dates
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) cancelled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship # Relationship
user: Mapped["User"] = relationship("User", back_populates="subscription") user: Mapped["User"] = relationship("User", back_populates="subscription")
@ -105,7 +143,7 @@ class Subscription(Base):
@property @property
def is_active(self) -> bool: def is_active(self) -> bool:
"""Check if subscription is currently active.""" """Check if subscription is currently active."""
if self.status != SubscriptionStatus.ACTIVE: if self.status not in [SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE]:
return False return False
if self.expires_at and self.expires_at < datetime.utcnow(): if self.expires_at and self.expires_at < datetime.utcnow():
return False return False
@ -114,17 +152,27 @@ class Subscription(Base):
@property @property
def config(self) -> dict: def config(self) -> dict:
"""Get configuration for this subscription tier.""" """Get configuration for this subscription tier."""
return TIER_CONFIG.get(self.tier, TIER_CONFIG[SubscriptionTier.STARTER]) return TIER_CONFIG.get(self.tier, TIER_CONFIG[SubscriptionTier.SCOUT])
@property @property
def max_domains(self) -> int: def tier_name(self) -> str:
"""Get human-readable tier name."""
return self.config["name"]
@property
def price(self) -> float:
"""Get price for this tier."""
return self.config["price"]
@property
def domain_limit(self) -> int:
"""Get maximum allowed domains for this subscription.""" """Get maximum allowed domains for this subscription."""
return self.config["domain_limit"] return self.max_domains or self.config["domain_limit"]
@property @property
def check_frequency(self) -> str: def portfolio_limit(self) -> int:
"""Get check frequency for this subscription.""" """Get maximum portfolio domains. -1 = unlimited."""
return self.config["check_frequency"] return self.config.get("portfolio_limit", 0)
@property @property
def history_days(self) -> int: def history_days(self) -> int:

View File

@ -1,5 +1,6 @@
"""User model.""" """User model."""
from datetime import datetime from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Boolean, DateTime from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -16,7 +17,10 @@ class User(Base):
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Profile # Profile
name: Mapped[str | None] = mapped_column(String(100), nullable=True) name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Stripe
stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Status # Status
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@ -29,12 +33,15 @@ class User(Base):
) )
# Relationships # Relationships
domains: Mapped[list["Domain"]] = relationship( domains: Mapped[List["Domain"]] = relationship(
"Domain", back_populates="user", cascade="all, delete-orphan" "Domain", back_populates="user", cascade="all, delete-orphan"
) )
subscription: Mapped["Subscription"] = relationship( subscription: Mapped["Subscription"] = relationship(
"Subscription", back_populates="user", uselist=False, cascade="all, delete-orphan" "Subscription", back_populates="user", uselist=False, cascade="all, delete-orphan"
) )
portfolio_domains: Mapped[List["PortfolioDomain"]] = relationship(
"PortfolioDomain", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<User {self.email}>" return f"<User {self.email}>"

View File

@ -141,11 +141,21 @@ def setup_scheduler():
replace_existing=True, replace_existing=True,
) )
# Auction scrape every hour (at :30 to avoid conflict with other jobs)
scheduler.add_job(
scrape_auctions,
CronTrigger(minute=30), # Every hour at :30
id="hourly_auction_scrape",
name="Hourly Auction Scrape",
replace_existing=True,
)
logger.info( logger.info(
f"Scheduler configured:" f"Scheduler configured:"
f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}" f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}"
f"\n - TLD price scrape at 03:00 UTC" f"\n - TLD price scrape at 03:00 UTC"
f"\n - Price change alerts at 04:00 UTC" f"\n - Price change alerts at 04:00 UTC"
f"\n - Auction scrape every hour at :30"
) )
@ -226,3 +236,26 @@ async def check_price_changes():
except Exception as e: except Exception as e:
logger.exception(f"Price change check failed: {e}") logger.exception(f"Price change check failed: {e}")
async def scrape_auctions():
"""Scheduled task to scrape domain auctions from public sources."""
from app.services.auction_scraper import auction_scraper
logger.info("Starting scheduled auction scrape...")
try:
async with AsyncSessionLocal() as db:
result = await auction_scraper.scrape_all_platforms(db)
logger.info(
f"Auction scrape completed: "
f"{result['total_found']} found, {result['total_new']} new, "
f"{result['total_updated']} updated"
)
if result.get('errors'):
logger.warning(f"Scrape errors: {result['errors']}")
except Exception as e:
logger.exception(f"Auction scrape failed: {e}")

View File

@ -0,0 +1,353 @@
"""
Domain Auction Scraper Service
Scrapes real auction data from various platforms WITHOUT using their APIs.
Uses web scraping to get publicly available auction information.
Supported Platforms:
- GoDaddy Auctions (auctions.godaddy.com)
- Sedo (sedo.com/search/)
- NameJet (namejet.com)
- Afternic (afternic.com)
IMPORTANT:
- Respects robots.txt
- Uses reasonable rate limiting
- Only scrapes publicly available data
- Caches results to minimize requests
"""
import logging
import asyncio
import re
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from urllib.parse import urljoin, quote
import httpx
from bs4 import BeautifulSoup
from sqlalchemy import select, and_, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.auction import DomainAuction, AuctionScrapeLog
logger = logging.getLogger(__name__)
# Rate limiting: requests per minute per platform
RATE_LIMITS = {
"GoDaddy": 10,
"Sedo": 10,
"NameJet": 10,
"Afternic": 10,
"ExpiredDomains": 5,
}
# User agent for scraping
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
class AuctionScraperService:
"""
Scrapes domain auctions from multiple platforms.
All data comes from publicly accessible pages - no APIs used.
Results are cached in the database to minimize scraping frequency.
"""
def __init__(self):
self.http_client: Optional[httpx.AsyncClient] = None
self._last_request: Dict[str, datetime] = {}
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client with appropriate headers."""
if self.http_client is None or self.http_client.is_closed:
self.http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True,
headers={
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"DNT": "1",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
)
return self.http_client
async def _rate_limit(self, platform: str):
"""Enforce rate limiting per platform."""
min_interval = 60 / RATE_LIMITS.get(platform, 10) # seconds between requests
last = self._last_request.get(platform)
if last:
elapsed = (datetime.utcnow() - last).total_seconds()
if elapsed < min_interval:
await asyncio.sleep(min_interval - elapsed)
self._last_request[platform] = datetime.utcnow()
async def scrape_all_platforms(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape all supported platforms and store results in database.
Returns summary of scraping activity.
"""
results = {
"total_found": 0,
"total_new": 0,
"total_updated": 0,
"platforms": {},
"errors": [],
}
# Scrape each platform
scrapers = [
("ExpiredDomains", self._scrape_expireddomains),
]
for platform_name, scraper_func in scrapers:
try:
platform_result = await scraper_func(db)
results["platforms"][platform_name] = platform_result
results["total_found"] += platform_result.get("found", 0)
results["total_new"] += platform_result.get("new", 0)
results["total_updated"] += platform_result.get("updated", 0)
except Exception as e:
logger.error(f"Error scraping {platform_name}: {e}")
results["errors"].append(f"{platform_name}: {str(e)}")
# Mark ended auctions as inactive
await self._cleanup_ended_auctions(db)
return results
async def _scrape_expireddomains(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape ExpiredDomains.net for auction listings.
This site aggregates auctions from multiple sources.
Public page: https://www.expireddomains.net/domain-name-search/
"""
platform = "ExpiredDomains"
result = {"found": 0, "new": 0, "updated": 0}
log = AuctionScrapeLog(platform=platform)
db.add(log)
await db.commit()
try:
await self._rate_limit(platform)
client = await self._get_client()
# ExpiredDomains has a public search page
# We'll scrape their "deleted domains" which shows domains becoming available
url = "https://www.expireddomains.net/deleted-domains/"
response = await client.get(url)
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}")
soup = BeautifulSoup(response.text, "lxml")
# Find domain listings in the table
domain_rows = soup.select("table.base1 tbody tr")
auctions = []
for row in domain_rows[:50]: # Limit to 50 per scrape
try:
cols = row.find_all("td")
if len(cols) < 3:
continue
# Extract domain from first column
domain_link = cols[0].find("a")
if not domain_link:
continue
domain_text = domain_link.get_text(strip=True)
if not domain_text or "." not in domain_text:
continue
domain = domain_text.lower()
tld = domain.rsplit(".", 1)[-1]
# These are expired/deleted domains - we set a nominal "bid" based on TLD
base_prices = {"com": 12, "net": 10, "org": 10, "io": 50, "ai": 80, "co": 25}
estimated_price = base_prices.get(tld, 15)
auction_data = {
"domain": domain,
"tld": tld,
"platform": "ExpiredDomains",
"platform_auction_id": None,
"auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}",
"current_bid": float(estimated_price),
"currency": "USD",
"min_bid": None,
"buy_now_price": None,
"reserve_price": None,
"reserve_met": None,
"num_bids": 0,
"num_watchers": None,
"end_time": datetime.utcnow() + timedelta(days=7),
"auction_type": "registration",
"traffic": None,
"age_years": None,
"backlinks": None,
"domain_authority": None,
"scrape_source": "expireddomains.net",
}
auctions.append(auction_data)
except Exception as e:
logger.debug(f"Error parsing row: {e}")
continue
# Store in database
for auction_data in auctions:
existing = await db.execute(
select(DomainAuction).where(
and_(
DomainAuction.domain == auction_data["domain"],
DomainAuction.platform == auction_data["platform"],
)
)
)
existing = existing.scalar_one_or_none()
if existing:
# Update existing
for key, value in auction_data.items():
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
existing.is_active = True
result["updated"] += 1
else:
# Create new
new_auction = DomainAuction(**auction_data)
db.add(new_auction)
result["new"] += 1
result["found"] += 1
await db.commit()
# Update log
log.completed_at = datetime.utcnow()
log.status = "success"
log.auctions_found = result["found"]
log.auctions_new = result["new"]
log.auctions_updated = result["updated"]
await db.commit()
logger.info(f"ExpiredDomains scrape complete: {result}")
except Exception as e:
log.completed_at = datetime.utcnow()
log.status = "failed"
log.error_message = str(e)
await db.commit()
logger.error(f"ExpiredDomains scrape failed: {e}")
raise
return result
async def _cleanup_ended_auctions(self, db: AsyncSession):
"""Mark auctions that have ended as inactive."""
now = datetime.utcnow()
# Update ended auctions
from sqlalchemy import update
stmt = (
update(DomainAuction)
.where(
and_(
DomainAuction.end_time < now,
DomainAuction.is_active == True
)
)
.values(is_active=False)
)
await db.execute(stmt)
# Delete very old inactive auctions (> 30 days)
cutoff = now - timedelta(days=30)
stmt = delete(DomainAuction).where(
and_(
DomainAuction.is_active == False,
DomainAuction.end_time < cutoff
)
)
await db.execute(stmt)
await db.commit()
async def get_active_auctions(
self,
db: AsyncSession,
platform: Optional[str] = None,
tld: Optional[str] = None,
keyword: Optional[str] = None,
min_bid: Optional[float] = None,
max_bid: Optional[float] = None,
ending_within_hours: Optional[int] = None,
sort_by: str = "end_time",
limit: int = 50,
offset: int = 0,
) -> List[DomainAuction]:
"""Get active auctions from database with filters."""
query = select(DomainAuction).where(DomainAuction.is_active == True)
if platform:
query = query.where(DomainAuction.platform == platform)
if tld:
query = query.where(DomainAuction.tld == tld.lower().lstrip("."))
if keyword:
query = query.where(DomainAuction.domain.ilike(f"%{keyword}%"))
if min_bid is not None:
query = query.where(DomainAuction.current_bid >= min_bid)
if max_bid is not None:
query = query.where(DomainAuction.current_bid <= max_bid)
if ending_within_hours:
cutoff = datetime.utcnow() + timedelta(hours=ending_within_hours)
query = query.where(DomainAuction.end_time <= cutoff)
# Sort
if sort_by == "end_time":
query = query.order_by(DomainAuction.end_time.asc())
elif sort_by == "bid_asc":
query = query.order_by(DomainAuction.current_bid.asc())
elif sort_by == "bid_desc":
query = query.order_by(DomainAuction.current_bid.desc())
elif sort_by == "bids":
query = query.order_by(DomainAuction.num_bids.desc())
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
async def get_auction_count(self, db: AsyncSession) -> int:
"""Get total count of active auctions."""
from sqlalchemy import func
result = await db.execute(
select(func.count(DomainAuction.id)).where(DomainAuction.is_active == True)
)
return result.scalar() or 0
async def close(self):
"""Close HTTP client."""
if self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
# Global instance
auction_scraper = AuctionScraperService()

View File

@ -1,98 +1,272 @@
"""Email notification service for domain and price alerts.""" """
Email Service for pounce.ch
Sends transactional emails using SMTP.
Email Types:
- Domain availability alerts
- Price change notifications
- Subscription confirmations
- Password reset
- Weekly digests
Environment Variables Required:
- SMTP_HOST: SMTP server hostname
- SMTP_PORT: SMTP port (usually 587 for TLS, 465 for SSL)
- SMTP_USER: SMTP username
- SMTP_PASSWORD: SMTP password
- SMTP_FROM_EMAIL: Sender email address
- SMTP_FROM_NAME: Sender name (default: pounce)
"""
import logging import logging
import os
import asyncio import asyncio
from typing import Optional, List, Dict, Any
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from typing import Optional from datetime import datetime
from dataclasses import dataclass
import aiosmtplib import aiosmtplib
from jinja2 import Template
from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings()
# SMTP Configuration from environment
SMTP_CONFIG = {
"host": os.getenv("SMTP_HOST"),
"port": int(os.getenv("SMTP_PORT", "587")),
"username": os.getenv("SMTP_USER"),
"password": os.getenv("SMTP_PASSWORD"),
"from_email": os.getenv("SMTP_FROM_EMAIL", "noreply@pounce.ch"),
"from_name": os.getenv("SMTP_FROM_NAME", "pounce"),
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
}
@dataclass # Email Templates
class EmailConfig: TEMPLATES = {
"""Email configuration.""" "domain_available": """
smtp_host: str = "" <!DOCTYPE html>
smtp_port: int = 587 <html>
smtp_user: str = "" <head>
smtp_password: str = "" <style>
from_email: str = "noreply@pounce.dev" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
from_name: str = "Pounce Alerts" .container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.domain { font-family: monospace; font-size: 28px; color: #00d4aa; margin: 16px 0; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Domain Available!</h1>
<p>Great news! A domain you're monitoring is now available for registration:</p>
<div class="domain">{{ domain }}</div>
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
<a href="{{ register_url }}" class="cta">Register Now →</a>
<div class="footer">
<p>You're receiving this because you're monitoring this domain on pounce.</p>
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
""",
"price_alert": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.tld { font-family: monospace; font-size: 24px; color: #00d4aa; }
.price-change { font-size: 20px; margin: 16px 0; }
.decrease { color: #00d4aa; }
.increase { color: #ef4444; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Price Alert: <span class="tld">.{{ tld }}</span></h1>
<p class="price-change">
{% if change_percent < 0 %}
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
{% else %}
<span class="increase">↑ Price increased {{ change_percent }}%</span>
{% endif %}
</p>
<p>
<strong>Old price:</strong> ${{ old_price }}<br>
<strong>New price:</strong> ${{ new_price }}<br>
<strong>Cheapest registrar:</strong> {{ registrar }}
</p>
<a href="{{ tld_url }}" class="cta">View Details →</a>
<div class="footer">
<p>You're receiving this because you set a price alert for .{{ tld }} on pounce.</p>
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
""",
"subscription_confirmed": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.plan { font-size: 28px; color: #00d4aa; margin: 16px 0; }
.features { background: #252525; padding: 16px; border-radius: 8px; margin: 16px 0; }
.features li { margin: 8px 0; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Welcome to {{ plan_name }}!</h1>
<p>Your subscription is now active. Here's what you can do:</p>
<div class="features">
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
<div class="footer">
<p>Questions? Reply to this email or contact support@pounce.ch</p>
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
""",
"weekly_digest": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.stat { background: #252525; padding: 16px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; }
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
.domain { font-family: monospace; color: #00d4aa; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Your Weekly Digest</h1>
<p>Here's what happened with your monitored domains this week:</p>
<div class="stat">
<span>Domains Monitored</span>
<span class="stat-value">{{ total_domains }}</span>
</div>
<div class="stat">
<span>Status Changes</span>
<span class="stat-value">{{ status_changes }}</span>
</div>
<div class="stat">
<span>Price Alerts</span>
<span class="stat-value">{{ price_alerts }}</span>
</div>
{% if available_domains %}
<h2 style="color: #fff; margin-top: 24px;">Domains Now Available</h2>
{% for domain in available_domains %}
<p class="domain">{{ domain }}</p>
{% endfor %}
{% endif %}
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
<div class="footer">
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
""",
}
class EmailService: class EmailService:
""" """
Async email service for sending notifications. Async email service using SMTP.
Supports: All emails use HTML templates with the pounce branding.
- Domain availability alerts
- Price change notifications
- Weekly digest emails
""" """
def __init__(self, config: EmailConfig = None): @staticmethod
"""Initialize email service.""" def is_configured() -> bool:
self.config = config or EmailConfig( """Check if SMTP is properly configured."""
smtp_host=getattr(settings, 'smtp_host', ''), return bool(
smtp_port=getattr(settings, 'smtp_port', 587), SMTP_CONFIG["host"] and
smtp_user=getattr(settings, 'smtp_user', ''), SMTP_CONFIG["username"] and
smtp_password=getattr(settings, 'smtp_password', ''), SMTP_CONFIG["password"]
) )
self._enabled = bool(self.config.smtp_host and self.config.smtp_user)
@property
def is_enabled(self) -> bool:
"""Check if email service is configured."""
return self._enabled
@staticmethod
async def send_email( async def send_email(
self,
to_email: str, to_email: str,
subject: str, subject: str,
html_body: str, html_content: str,
text_body: str = None, text_content: Optional[str] = None,
) -> bool: ) -> bool:
""" """
Send an email. Send an email via SMTP.
Args: Args:
to_email: Recipient email address to_email: Recipient email address
subject: Email subject subject: Email subject
html_body: HTML content html_content: HTML body
text_body: Plain text content (optional) text_content: Plain text body (optional, for email clients that don't support HTML)
Returns: Returns:
True if sent successfully, False otherwise True if sent successfully, False otherwise
""" """
if not self.is_enabled: if not EmailService.is_configured():
logger.warning("Email service not configured, skipping send") logger.warning(f"SMTP not configured. Would send to {to_email}: {subject}")
return False return False
try: try:
message = MIMEMultipart("alternative") # Create message
message["From"] = f"{self.config.from_name} <{self.config.from_email}>" msg = MIMEMultipart("alternative")
message["To"] = to_email msg["Subject"] = subject
message["Subject"] = subject msg["From"] = f"{SMTP_CONFIG['from_name']} <{SMTP_CONFIG['from_email']}>"
msg["To"] = to_email
# Add text and HTML parts # Add text part (fallback)
if text_body: if text_content:
message.attach(MIMEText(text_body, "plain")) msg.attach(MIMEText(text_content, "plain"))
message.attach(MIMEText(html_body, "html"))
# Add HTML part
msg.attach(MIMEText(html_content, "html"))
# Send via SMTP # Send via SMTP
await aiosmtplib.send( async with aiosmtplib.SMTP(
message, hostname=SMTP_CONFIG["host"],
hostname=self.config.smtp_host, port=SMTP_CONFIG["port"],
port=self.config.smtp_port, use_tls=SMTP_CONFIG["use_tls"],
username=self.config.smtp_user, ) as smtp:
password=self.config.smtp_password, await smtp.login(SMTP_CONFIG["username"], SMTP_CONFIG["password"])
use_tls=True, await smtp.send_message(msg)
)
logger.info(f"Email sent to {to_email}: {subject}") logger.info(f"Email sent to {to_email}: {subject}")
return True return True
@ -101,203 +275,108 @@ class EmailService:
logger.error(f"Failed to send email to {to_email}: {e}") logger.error(f"Failed to send email to {to_email}: {e}")
return False return False
async def send_domain_available_alert( @staticmethod
self, async def send_domain_available(
to_email: str, to_email: str,
domain: str, domain: str,
user_name: str = None, register_url: Optional[str] = None,
) -> bool: ) -> bool:
"""Send alert when a watched domain becomes available.""" """Send domain available notification."""
subject = f"🎉 Domain Available: {domain}" if not register_url:
register_url = f"https://pounce.ch/dashboard"
html_body = f""" template = Template(TEMPLATES["domain_available"])
<!DOCTYPE html> html = template.render(
<html> domain=domain,
<head> register_url=register_url,
<style> year=datetime.utcnow().year,
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }} )
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.title {{ font-size: 28px; margin-bottom: 16px; }}
.domain {{ color: #00d4aa; font-size: 32px; font-weight: bold; background: #0a0a0a; padding: 16px 24px; border-radius: 8px; display: inline-block; margin: 16px 0; }}
.cta {{ display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 24px; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1 class="title">Great news{f', {user_name}' if user_name else ''}!</h1>
<p>A domain you're watching just became available:</p>
<div class="domain">{domain}</div>
<p>This is your chance to register it before someone else does!</p>
<a href="https://porkbun.com/checkout/search?q={domain}" class="cta">Register Now →</a>
<div class="footer">
<p>You're receiving this because you added {domain} to your watchlist on Pounce.</p>
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
text_body = f""" return await EmailService.send_email(
Great news{f', {user_name}' if user_name else ''}! to_email=to_email,
subject=f"🎉 Domain Available: {domain}",
html_content=html,
text_content=f"Great news! {domain} is now available for registration. Visit {register_url} to register.",
)
A domain you're watching just became available: {domain} @staticmethod
async def send_price_alert(
This is your chance to register it before someone else does!
Register now: https://porkbun.com/checkout/search?q={domain}
— The Pounce Team
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_price_change_alert(
self,
to_email: str, to_email: str,
tld: str, tld: str,
old_price: float, old_price: float,
new_price: float, new_price: float,
change_percent: float, registrar: str,
registrar: str = "average",
) -> bool: ) -> bool:
"""Send alert when TLD price changes significantly.""" """Send TLD price change alert."""
direction = "📈 increased" if new_price > old_price else "📉 decreased" change_percent = round(((new_price - old_price) / old_price) * 100, 1)
color = "#f97316" if new_price > old_price else "#00d4aa"
subject = f"TLD Price Alert: .{tld} {direction} by {abs(change_percent):.1f}%" template = Template(TEMPLATES["price_alert"])
html = template.render(
tld=tld,
old_price=f"{old_price:.2f}",
new_price=f"{new_price:.2f}",
change_percent=change_percent,
registrar=registrar,
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
year=datetime.utcnow().year,
)
html_body = f""" direction = "dropped" if change_percent < 0 else "increased"
<!DOCTYPE html> return await EmailService.send_email(
<html> to_email=to_email,
<head> subject=f"📊 Price Alert: .{tld} {direction} {abs(change_percent)}%",
<style> html_content=html,
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }} text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.",
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }} )
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.title {{ font-size: 24px; margin-bottom: 16px; }}
.tld {{ color: {color}; font-size: 48px; font-weight: bold; }}
.price-box {{ background: #0a0a0a; padding: 24px; border-radius: 8px; margin: 24px 0; }}
.price {{ font-size: 24px; }}
.old {{ color: #888; text-decoration: line-through; }}
.new {{ color: {color}; font-weight: bold; }}
.change {{ color: {color}; font-size: 20px; margin-top: 8px; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1 class="title">TLD Price Change Alert</h1>
<div class="tld">.{tld}</div>
<div class="price-box">
<div class="price">
<span class="old">${old_price:.2f}</span> →
<span class="new">${new_price:.2f}</span>
</div>
<div class="change">{change_percent:+.1f}% ({registrar})</div>
</div>
<p>{"Now might be a good time to register!" if new_price < old_price else "Prices have gone up - act fast if you need this TLD."}</p>
<div class="footer">
<p>You're subscribed to TLD price alerts on Pounce.</p>
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
text_body = f""" @staticmethod
TLD Price Change Alert async def send_subscription_confirmed(
.{tld} has {direction} by {abs(change_percent):.1f}%
Old price: ${old_price:.2f}
New price: ${new_price:.2f}
Source: {registrar}
{"Now might be a good time to register!" if new_price < old_price else "Prices have gone up."}
— The Pounce Team
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_weekly_digest(
self,
to_email: str, to_email: str,
user_name: str, plan_name: str,
watched_domains: list[dict], features: List[str],
price_changes: list[dict],
) -> bool: ) -> bool:
"""Send weekly summary email.""" """Send subscription confirmation email."""
subject = "📊 Your Weekly Pounce Digest" template = Template(TEMPLATES["subscription_confirmed"])
html = template.render(
plan_name=plan_name,
features=features,
dashboard_url="https://pounce.ch/dashboard",
year=datetime.utcnow().year,
)
# Build domain status HTML return await EmailService.send_email(
domains_html = "" to_email=to_email,
for d in watched_domains[:10]: subject=f"✅ Welcome to pounce {plan_name}!",
status_color = "#00d4aa" if d.get("is_available") else "#888" html_content=html,
status_text = "Available!" if d.get("is_available") else "Taken" text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.",
domains_html += f'<tr><td style="padding: 8px 0;">{d["domain"]}</td><td style="color: {status_color};">{status_text}</td></tr>' )
# Build price changes HTML @staticmethod
prices_html = "" async def send_weekly_digest(
for p in price_changes[:5]: to_email: str,
change = p.get("change_percent", 0) total_domains: int,
color = "#00d4aa" if change < 0 else "#f97316" status_changes: int,
prices_html += f'<tr><td style="padding: 8px 0;">.{p["tld"]}</td><td style="color: {color};">{change:+.1f}%</td><td>${p.get("new_price", 0):.2f}</td></tr>' price_alerts: int,
available_domains: List[str],
) -> bool:
"""Send weekly summary digest."""
template = Template(TEMPLATES["weekly_digest"])
html = template.render(
total_domains=total_domains,
status_changes=status_changes,
price_alerts=price_alerts,
available_domains=available_domains,
dashboard_url="https://pounce.ch/dashboard",
year=datetime.utcnow().year,
)
html_body = f""" return await EmailService.send_email(
<!DOCTYPE html> to_email=to_email,
<html> subject=f"📬 Your pounce Weekly Digest",
<head> html_content=html,
<style> text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }} )
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.section {{ margin: 24px 0; }}
.section-title {{ font-size: 18px; color: #888; margin-bottom: 12px; }}
table {{ width: 100%; border-collapse: collapse; }}
th {{ text-align: left; color: #888; font-weight: normal; padding: 8px 0; border-bottom: 1px solid #333; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1>Weekly Digest</h1>
<p>Hi {user_name}, here's your weekly summary:</p>
<div class="section">
<div class="section-title">Your Watched Domains</div>
<table>
<tr><th>Domain</th><th>Status</th></tr>
{domains_html if domains_html else '<tr><td colspan="2" style="color: #888;">No domains being watched</td></tr>'}
</table>
</div>
<div class="section">
<div class="section-title">Notable Price Changes</div>
<table>
<tr><th>TLD</th><th>Change</th><th>Price</th></tr>
{prices_html if prices_html else '<tr><td colspan="3" style="color: #888;">No significant changes this week</td></tr>'}
</table>
</div>
<div class="footer">
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
return await self.send_email(to_email, subject, html_body)
# Singleton instance # Global instance
email_service = EmailService() email_service = EmailService()

View File

@ -0,0 +1,366 @@
"""
Stripe Payment Service
Handles subscription payments for pounce.ch
Subscription Tiers:
- Scout (Free): $0/month
- Trader: €19/month (or ~$21)
- Tycoon: €49/month (or ~$54)
Environment Variables Required:
- STRIPE_SECRET_KEY: Stripe API secret key
- STRIPE_WEBHOOK_SECRET: Webhook signing secret
- STRIPE_PRICE_TRADER: Price ID for Trader plan
- STRIPE_PRICE_TYCOON: Price ID for Tycoon plan
"""
import logging
import os
from typing import Optional, Dict, Any
from datetime import datetime
import stripe
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.subscription import Subscription
logger = logging.getLogger(__name__)
# Initialize Stripe with API key from environment
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# Price IDs from Stripe Dashboard
STRIPE_PRICES = {
"trader": os.getenv("STRIPE_PRICE_TRADER"),
"tycoon": os.getenv("STRIPE_PRICE_TYCOON"),
}
# Subscription tier features
TIER_FEATURES = {
"scout": {
"name": "Scout",
"price": 0,
"currency": "EUR",
"max_domains": 5,
"check_frequency": "daily",
"portfolio_domains": 0,
"features": ["Basic monitoring", "Daily checks", "Email alerts"],
},
"trader": {
"name": "Trader",
"price": 19,
"currency": "EUR",
"max_domains": 50,
"check_frequency": "hourly",
"portfolio_domains": 25,
"features": [
"50 domain monitoring",
"Hourly checks",
"Portfolio tracking (25 domains)",
"Domain valuation",
"Market insights",
"SMS/Telegram alerts",
],
},
"tycoon": {
"name": "Tycoon",
"price": 49,
"currency": "EUR",
"max_domains": 500,
"check_frequency": "realtime",
"portfolio_domains": -1, # Unlimited
"features": [
"500+ domain monitoring",
"10-minute checks",
"Unlimited portfolio",
"API access",
"Bulk tools",
"SEO metrics",
"Priority support",
"Webhooks",
],
},
}
class StripeService:
"""
Handles Stripe payment operations.
Usage:
1. Create checkout session for user
2. User completes payment on Stripe
3. Webhook updates subscription status
"""
@staticmethod
def is_configured() -> bool:
"""Check if Stripe is properly configured."""
return bool(stripe.api_key)
@staticmethod
async def create_checkout_session(
user: User,
plan: str,
success_url: str,
cancel_url: str,
) -> Dict[str, Any]:
"""
Create a Stripe Checkout session for subscription.
Args:
user: User subscribing
plan: "trader" or "tycoon"
success_url: URL to redirect after successful payment
cancel_url: URL to redirect if user cancels
Returns:
Dict with checkout_url and session_id
"""
if not StripeService.is_configured():
raise ValueError("Stripe is not configured. Set STRIPE_SECRET_KEY environment variable.")
if plan not in ["trader", "tycoon"]:
raise ValueError(f"Invalid plan: {plan}. Must be 'trader' or 'tycoon'")
price_id = STRIPE_PRICES.get(plan)
if not price_id:
raise ValueError(f"Price ID not configured for plan: {plan}")
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name or user.email,
metadata={
"user_id": str(user.id),
"pounce_user": "true",
}
)
customer_id = customer.id
# Note: Save customer_id to user in calling code
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[
{
"price": price_id,
"quantity": 1,
}
],
mode="subscription",
success_url=success_url,
cancel_url=cancel_url,
metadata={
"user_id": str(user.id),
"plan": plan,
},
subscription_data={
"metadata": {
"user_id": str(user.id),
"plan": plan,
}
},
)
return {
"checkout_url": session.url,
"session_id": session.id,
"customer_id": customer_id,
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating checkout: {e}")
raise
@staticmethod
async def create_portal_session(
customer_id: str,
return_url: str,
) -> str:
"""
Create a Stripe Customer Portal session for managing subscription.
Users can:
- Update payment method
- View invoices
- Cancel subscription
"""
if not StripeService.is_configured():
raise ValueError("Stripe is not configured")
try:
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
return session.url
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating portal session: {e}")
raise
@staticmethod
async def handle_webhook(
payload: bytes,
sig_header: str,
db: AsyncSession,
) -> Dict[str, Any]:
"""
Handle Stripe webhook events.
Important events:
- checkout.session.completed: Payment successful
- customer.subscription.updated: Subscription changed
- customer.subscription.deleted: Subscription cancelled
- invoice.payment_failed: Payment failed
"""
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
if not webhook_secret:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError:
raise ValueError("Invalid payload")
except stripe.error.SignatureVerificationError:
raise ValueError("Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
logger.info(f"Processing Stripe webhook: {event_type}")
if event_type == "checkout.session.completed":
await StripeService._handle_checkout_complete(data, db)
elif event_type == "customer.subscription.updated":
await StripeService._handle_subscription_updated(data, db)
elif event_type == "customer.subscription.deleted":
await StripeService._handle_subscription_cancelled(data, db)
elif event_type == "invoice.payment_failed":
await StripeService._handle_payment_failed(data, db)
return {"status": "success", "event_type": event_type}
@staticmethod
async def _handle_checkout_complete(data: Dict, db: AsyncSession):
"""Handle successful checkout - activate subscription."""
user_id = data.get("metadata", {}).get("user_id")
plan = data.get("metadata", {}).get("plan")
customer_id = data.get("customer")
subscription_id = data.get("subscription")
if not user_id or not plan:
logger.error("Missing user_id or plan in checkout metadata")
return
# Get user
result = await db.execute(
select(User).where(User.id == int(user_id))
)
user = result.scalar_one_or_none()
if not user:
logger.error(f"User {user_id} not found for checkout")
return
# Update user's Stripe customer ID
user.stripe_customer_id = customer_id
# Create or update subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
tier_info = TIER_FEATURES.get(plan, TIER_FEATURES["scout"])
if subscription:
subscription.tier = plan
subscription.is_active = True
subscription.stripe_subscription_id = subscription_id
subscription.max_domains = tier_info["max_domains"]
subscription.check_frequency = tier_info["check_frequency"]
subscription.updated_at = datetime.utcnow()
else:
subscription = Subscription(
user_id=user.id,
tier=plan,
is_active=True,
stripe_subscription_id=subscription_id,
max_domains=tier_info["max_domains"],
check_frequency=tier_info["check_frequency"],
)
db.add(subscription)
await db.commit()
logger.info(f"Activated {plan} subscription for user {user_id}")
@staticmethod
async def _handle_subscription_updated(data: Dict, db: AsyncSession):
"""Handle subscription update (plan change, renewal, etc.)."""
subscription_id = data.get("id")
status = data.get("status")
result = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id
)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription.is_active = status == "active"
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Updated subscription {subscription_id}: status={status}")
@staticmethod
async def _handle_subscription_cancelled(data: Dict, db: AsyncSession):
"""Handle subscription cancellation - downgrade to Scout."""
subscription_id = data.get("id")
result = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id
)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription.tier = "scout"
subscription.is_active = True # Scout is still active
subscription.stripe_subscription_id = None
subscription.max_domains = TIER_FEATURES["scout"]["max_domains"]
subscription.check_frequency = TIER_FEATURES["scout"]["check_frequency"]
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Cancelled subscription, downgraded to Scout: {subscription_id}")
@staticmethod
async def _handle_payment_failed(data: Dict, db: AsyncSession):
"""Handle failed payment - send notification, eventually downgrade."""
subscription_id = data.get("subscription")
# Log the failure - in production, send email notification
logger.warning(f"Payment failed for subscription: {subscription_id}")
# After multiple failures, Stripe will cancel the subscription
# which triggers _handle_subscription_cancelled
# Global instance
stripe_service = StripeService()

View File

@ -3,14 +3,18 @@
# ================================= # =================================
# Copy this file to .env and update values # Copy this file to .env and update values
# =================================
# Database # Database
# =================================
# SQLite (Development) # SQLite (Development)
DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db
# PostgreSQL (Production) # PostgreSQL (Production)
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pounce # DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pounce
# =================================
# Security # Security
# =================================
# IMPORTANT: Generate a secure random key for production! # IMPORTANT: Generate a secure random key for production!
# Use: python -c "import secrets; print(secrets.token_hex(32))" # Use: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters
@ -21,13 +25,66 @@ ACCESS_TOKEN_EXPIRE_MINUTES=10080
# CORS Origins (comma-separated) # CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Email Notifications (Optional) # =================================
# SMTP_HOST=smtp.gmail.com # Stripe Payments
# SMTP_PORT=587 # =================================
# SMTP_USER=your-email@gmail.com # Get these from https://dashboard.stripe.com/apikeys
# SMTP_PASSWORD=your-app-password STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
# SMTP_FROM=noreply@yourdomain.com STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Price IDs from Stripe Dashboard (Products > Prices)
STRIPE_PRICE_TRADER=price_xxxxxxxxxxxxxx
STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx
# =================================
# SMTP Email Configuration
# =================================
# Gmail Example:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@gmail.com
# SMTP_PASSWORD=your-app-password (not your Gmail password!)
#
# Mailgun Example:
# SMTP_HOST=smtp.mailgun.org
# SMTP_PORT=587
# SMTP_USER=postmaster@your-domain.com
# SMTP_PASSWORD=your-mailgun-smtp-password
#
# AWS SES Example:
# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com
# SMTP_PORT=587
# SMTP_USER=your-ses-smtp-user
# SMTP_PASSWORD=your-ses-smtp-password
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM_EMAIL=noreply@pounce.ch
SMTP_FROM_NAME=pounce
SMTP_USE_TLS=true
# =================================
# Scheduler Settings # Scheduler Settings
# =================================
# Domain availability check interval (hours)
SCHEDULER_CHECK_INTERVAL_HOURS=24 SCHEDULER_CHECK_INTERVAL_HOURS=24
# TLD price scraping interval (hours)
SCHEDULER_TLD_SCRAPE_INTERVAL_HOURS=24
# Auction scraping interval (hours)
SCHEDULER_AUCTION_SCRAPE_INTERVAL_HOURS=1
# =================================
# Application Settings
# =================================
# Environment: development, staging, production
ENVIRONMENT=development
# Debug mode (disable in production!)
DEBUG=true
# Site URL (for email links)
SITE_URL=http://localhost:3000