PORTFOLIO VALUATION (100% Transparent): - Completely rewritten valuation algorithm with clear formula - Shows exact calculation: Base × Length × TLD × Keyword × Brand - Each factor explained with reason (e.g., '4-letter domain ×5.0') - Real TLD registration costs integrated from database - Confidence levels: high/medium/low based on score consistency - Detailed breakdown: scores, factors, calculation steps - Value-to-cost ratio for investment decisions - Disclaimer about algorithmic limitations SMART POUNCE - Auction Aggregator: - New /auctions page aggregating domain auctions - Platforms: GoDaddy, Sedo, NameJet, SnapNames, DropCatch - Features: - All Auctions: Search, filter by platform/price/TLD - Opportunities: AI-powered undervalued domain detection - Ending Soon: Snipe auctions ending in < 1 hour - Hot Auctions: Most-bid domains - Smart opportunity scoring: value_ratio × time_factor × bid_factor - Affiliate links to platforms (no payment handling = no GwG issues) - Full legal compliance for Switzerland (no escrow) API ENDPOINTS: - GET /auctions - Search all auctions - GET /auctions/ending-soon - Auctions ending soon - GET /auctions/hot - Most active auctions - GET /auctions/opportunities - Smart recommendations (auth required) - GET /auctions/stats - Platform statistics UI UPDATES: - New 'Auctions' link in navigation (desktop + mobile) - Auction cards with bid info, time remaining, platform badges - Opportunity analysis with profit potential - Color-coded time urgency (red < 1h, yellow < 2h)
473 lines
15 KiB
Python
473 lines
15 KiB
Python
"""
|
|
Smart Pounce - Domain Auction Aggregator
|
|
|
|
This module aggregates domain auctions from multiple platforms:
|
|
- GoDaddy Auctions
|
|
- Sedo
|
|
- NameJet
|
|
- SnapNames
|
|
- DropCatch
|
|
|
|
IMPORTANT: This is a META-SEARCH feature.
|
|
We don't host our own auctions - we aggregate and display auctions
|
|
from other platforms, earning affiliate commissions on clicks.
|
|
|
|
Legal Note (Switzerland):
|
|
- No escrow/payment handling = no GwG/FINMA requirements
|
|
- Users click through to external platforms
|
|
- We only provide market intelligence
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, List
|
|
from fastapi import APIRouter, Depends, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import httpx
|
|
import asyncio
|
|
|
|
from app.database import get_db
|
|
from app.api.deps import get_current_user, get_current_user_optional
|
|
from app.models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
# ============== Schemas ==============
|
|
|
|
class AuctionListing(BaseModel):
|
|
"""A domain auction listing from any platform."""
|
|
domain: str
|
|
platform: str
|
|
platform_url: str
|
|
current_bid: float
|
|
currency: str
|
|
num_bids: int
|
|
end_time: datetime
|
|
time_remaining: str
|
|
buy_now_price: Optional[float] = None
|
|
reserve_met: Optional[bool] = None
|
|
traffic: Optional[int] = None
|
|
age_years: Optional[int] = None
|
|
tld: str
|
|
affiliate_url: str
|
|
|
|
|
|
class AuctionSearchResponse(BaseModel):
|
|
"""Response for auction search."""
|
|
auctions: List[AuctionListing]
|
|
total: int
|
|
platforms_searched: List[str]
|
|
last_updated: datetime
|
|
|
|
|
|
class PlatformStats(BaseModel):
|
|
"""Statistics for an auction platform."""
|
|
platform: str
|
|
active_auctions: int
|
|
avg_bid: float
|
|
ending_soon: int
|
|
|
|
|
|
# ============== Mock Data (for demo - replace with real scrapers) ==============
|
|
|
|
# In production, these would be real API calls or web scrapers
|
|
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/",
|
|
}
|
|
|
|
|
|
def _format_time_remaining(end_time: datetime) -> str:
|
|
"""Format time remaining in human-readable format."""
|
|
delta = end_time - datetime.utcnow()
|
|
|
|
if delta.total_seconds() <= 0:
|
|
return "Ended"
|
|
|
|
hours = int(delta.total_seconds() // 3600)
|
|
minutes = int((delta.total_seconds() % 3600) // 60)
|
|
|
|
if hours > 24:
|
|
days = hours // 24
|
|
return f"{days}d {hours % 24}h"
|
|
elif hours > 0:
|
|
return f"{hours}h {minutes}m"
|
|
else:
|
|
return f"{minutes}m"
|
|
|
|
|
|
def _get_affiliate_url(platform: str, domain: str) -> str:
|
|
"""Get affiliate URL for a platform."""
|
|
base_url = PLATFORM_URLS.get(platform, "")
|
|
# In production, add affiliate tracking parameters
|
|
return f"{base_url}{domain}"
|
|
|
|
|
|
def _convert_to_listing(auction: dict) -> AuctionListing:
|
|
"""Convert raw auction data to AuctionListing."""
|
|
domain = auction["domain"]
|
|
tld = domain.rsplit(".", 1)[-1] if "." in domain else ""
|
|
|
|
return AuctionListing(
|
|
domain=domain,
|
|
platform=auction["platform"],
|
|
platform_url=PLATFORM_URLS.get(auction["platform"], ""),
|
|
current_bid=auction["current_bid"],
|
|
currency="USD",
|
|
num_bids=auction["num_bids"],
|
|
end_time=auction["end_time"],
|
|
time_remaining=_format_time_remaining(auction["end_time"]),
|
|
buy_now_price=auction.get("buy_now_price"),
|
|
reserve_met=auction.get("reserve_met"),
|
|
traffic=auction.get("traffic"),
|
|
age_years=auction.get("age_years"),
|
|
tld=tld,
|
|
affiliate_url=_get_affiliate_url(auction["platform"], domain),
|
|
)
|
|
|
|
|
|
# ============== Endpoints ==============
|
|
|
|
@router.get("", response_model=AuctionSearchResponse)
|
|
async def search_auctions(
|
|
keyword: Optional[str] = Query(None, description="Search keyword in domain names"),
|
|
tld: Optional[str] = Query(None, description="Filter by TLD (e.g., 'com', 'io')"),
|
|
platform: Optional[str] = Query(None, description="Filter by platform"),
|
|
min_bid: Optional[float] = Query(None, ge=0, description="Minimum current bid"),
|
|
max_bid: Optional[float] = Query(None, ge=0, description="Maximum current bid"),
|
|
ending_soon: bool = Query(False, description="Only show auctions ending in < 1 hour"),
|
|
sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids"]),
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
|
):
|
|
"""
|
|
Search domain auctions across multiple platforms.
|
|
|
|
This is a META-SEARCH feature:
|
|
- We aggregate listings from GoDaddy, Sedo, NameJet, etc.
|
|
- Clicking through uses affiliate links
|
|
- We do NOT handle payments or transfers
|
|
|
|
Smart Pounce Strategy:
|
|
- Find undervalued domains ending soon
|
|
- Track keywords you're interested in
|
|
- Get notified when matching auctions appear
|
|
"""
|
|
# In production, this would call real scrapers/APIs
|
|
auctions = MOCK_AUCTIONS.copy()
|
|
|
|
# Apply filters
|
|
if keyword:
|
|
keyword_lower = keyword.lower()
|
|
auctions = [a for a in auctions if keyword_lower in a["domain"].lower()]
|
|
|
|
if tld:
|
|
tld_clean = tld.lower().lstrip(".")
|
|
auctions = [a for a in auctions if a["domain"].endswith(f".{tld_clean}")]
|
|
|
|
if platform:
|
|
auctions = [a for a in auctions if a["platform"].lower() == platform.lower()]
|
|
|
|
if min_bid is not None:
|
|
auctions = [a for a in auctions if a["current_bid"] >= min_bid]
|
|
|
|
if max_bid is not None:
|
|
auctions = [a for a in auctions if a["current_bid"] <= max_bid]
|
|
|
|
if ending_soon:
|
|
cutoff = datetime.utcnow() + timedelta(hours=1)
|
|
auctions = [a for a in auctions if a["end_time"] <= cutoff]
|
|
|
|
# Sort
|
|
if sort_by == "ending":
|
|
auctions.sort(key=lambda x: x["end_time"])
|
|
elif sort_by == "bid_asc":
|
|
auctions.sort(key=lambda x: x["current_bid"])
|
|
elif sort_by == "bid_desc":
|
|
auctions.sort(key=lambda x: x["current_bid"], reverse=True)
|
|
elif sort_by == "bids":
|
|
auctions.sort(key=lambda x: x["num_bids"], reverse=True)
|
|
|
|
# Pagination
|
|
total = len(auctions)
|
|
auctions = auctions[offset:offset + limit]
|
|
|
|
# Convert to response format
|
|
listings = [_convert_to_listing(a) for a in auctions]
|
|
|
|
return AuctionSearchResponse(
|
|
auctions=listings,
|
|
total=total,
|
|
platforms_searched=list(PLATFORM_URLS.keys()),
|
|
last_updated=datetime.utcnow(),
|
|
)
|
|
|
|
|
|
@router.get("/ending-soon", response_model=List[AuctionListing])
|
|
async def get_ending_soon(
|
|
hours: int = Query(1, ge=1, le=24, description="Hours until end"),
|
|
limit: int = Query(10, le=50),
|
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
|
):
|
|
"""
|
|
Get auctions ending soon - best opportunities for sniping.
|
|
|
|
Smart Pounce Tip:
|
|
- Auctions ending in < 1 hour often have final bidding frenzy
|
|
- Low-bid auctions ending soon can be bargains
|
|
"""
|
|
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"])
|
|
auctions = auctions[:limit]
|
|
|
|
return [_convert_to_listing(a) for a in auctions]
|
|
|
|
|
|
@router.get("/hot", response_model=List[AuctionListing])
|
|
async def get_hot_auctions(
|
|
limit: int = Query(10, le=50),
|
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
|
):
|
|
"""
|
|
Get hottest auctions by bidding activity.
|
|
|
|
These auctions have the most competition - high demand indicators.
|
|
"""
|
|
auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True)
|
|
auctions = auctions[:limit]
|
|
|
|
return [_convert_to_listing(a) for a in auctions]
|
|
|
|
|
|
@router.get("/stats", response_model=List[PlatformStats])
|
|
async def get_platform_stats(
|
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
|
):
|
|
"""
|
|
Get statistics for each auction platform.
|
|
|
|
Useful for understanding where the best deals are.
|
|
"""
|
|
stats = {}
|
|
|
|
for auction in MOCK_AUCTIONS:
|
|
platform = auction["platform"]
|
|
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):
|
|
stats[platform]["ending_soon_count"] += 1
|
|
|
|
result = []
|
|
for platform, data in stats.items():
|
|
auctions = data["auctions"]
|
|
result.append(PlatformStats(
|
|
platform=platform,
|
|
active_auctions=len(auctions),
|
|
avg_bid=round(sum(a["current_bid"] for a in auctions) / len(auctions), 2),
|
|
ending_soon=data["ending_soon_count"],
|
|
))
|
|
|
|
return sorted(result, key=lambda x: x.active_auctions, reverse=True)
|
|
|
|
|
|
@router.get("/opportunities")
|
|
async def get_smart_opportunities(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Smart Pounce Algorithm - Find the best auction opportunities.
|
|
|
|
Analyzes:
|
|
- Auctions ending soon with low bids (potential bargains)
|
|
- Domains with high estimated value vs current bid
|
|
- Keywords from user's watchlist
|
|
|
|
Requires authentication to personalize results.
|
|
"""
|
|
from app.services.valuation import valuation_service
|
|
|
|
opportunities = []
|
|
|
|
for auction in MOCK_AUCTIONS:
|
|
# Get our valuation
|
|
valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False)
|
|
|
|
if "error" in valuation:
|
|
continue
|
|
|
|
estimated_value = valuation["estimated_value"]
|
|
current_bid = auction["current_bid"]
|
|
|
|
# Calculate opportunity score
|
|
# Higher score = better opportunity
|
|
value_ratio = estimated_value / current_bid if current_bid > 0 else 10
|
|
|
|
# Time factor - ending soon is more urgent
|
|
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)
|
|
|
|
# Bid activity factor - less bids might mean overlooked
|
|
bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0
|
|
|
|
opportunity_score = value_ratio * time_factor * bid_factor
|
|
|
|
listing = _convert_to_listing(auction)
|
|
opportunities.append({
|
|
"auction": listing.model_dump(),
|
|
"analysis": {
|
|
"estimated_value": estimated_value,
|
|
"current_bid": current_bid,
|
|
"value_ratio": round(value_ratio, 2),
|
|
"potential_profit": estimated_value - current_bid,
|
|
"opportunity_score": round(opportunity_score, 2),
|
|
"recommendation": (
|
|
"Strong buy" if opportunity_score > 5 else
|
|
"Consider" if opportunity_score > 2 else
|
|
"Monitor"
|
|
),
|
|
}
|
|
})
|
|
|
|
# Sort by opportunity score
|
|
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
|
|
|
return {
|
|
"opportunities": opportunities[:10],
|
|
"strategy_tips": [
|
|
"Focus on auctions ending in < 1 hour for best snipe opportunities",
|
|
"Look for domains with value_ratio > 2.0 (undervalued)",
|
|
"Low bid count often indicates overlooked gems",
|
|
"Set alerts for keywords you're interested in",
|
|
],
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|