yves.gugger dc77b2110a feat: Complete Watchlist monitoring, Portfolio tracking & Listings marketplace
## Watchlist & Monitoring
-  Automatic domain monitoring based on subscription tier
-  Email alerts when domains become available
-  Health checks (DNS/HTTP/SSL) with caching
-  Expiry warnings for domains <30 days
-  Weekly digest emails
-  Instant alert toggle (optimistic UI updates)
-  Redesigned health check overlays with full details
- 🔒 'Not public' display for .ch/.de domains without public expiry

## Portfolio Management (NEW)
-  Track owned domains with purchase price & date
-  ROI calculation (unrealized & realized)
-  Domain valuation with auto-refresh
-  Renewal date tracking
-  Sale recording with profit calculation
-  List domains for sale directly from portfolio
-  Full portfolio summary dashboard

## Listings / For Sale
-  Renamed from 'Portfolio' to 'For Sale'
-  Fixed listing limits: Scout=0, Trader=5, Tycoon=50
-  Featured badge for Tycoon listings
-  Inquiries modal for sellers
-  Email notifications when buyer inquires
-  Inquiries column in listings table

## Scrapers & Data
-  Added 4 new registrar scrapers (Namecheap, Cloudflare, GoDaddy, Dynadot)
-  Increased scraping frequency to 2x daily (03:00 & 15:00 UTC)
-  Real historical data from database
-  Fixed RDAP/WHOIS for .ch/.de domains
-  Enhanced SSL certificate parsing

## Scheduler Jobs
-  Tiered domain checks (Scout=daily, Trader=hourly, Tycoon=10min)
-  Daily health checks (06:00 UTC)
-  Weekly expiry warnings (Mon 08:00 UTC)
-  Weekly digest emails (Sun 10:00 UTC)
-  Auction cleanup every 15 minutes

## UI/UX Improvements
-  Removed 'Back' buttons from Intel pages
-  Redesigned Radar page to match Market/Intel design
-  Less prominent check frequency footer
-  Consistent StatCard components across all pages
-  Ambient background glows
-  Better error handling

## Documentation
-  Updated README with monitoring section
-  Added env.example with all required variables
-  Updated Memory Bank (activeContext.md)
-  SMTP configuration requirements documented
2025-12-11 16:57:28 +01:00

313 lines
11 KiB
Python

"""TLD Price Aggregator - combines multiple scrapers and saves to database."""
import logging
from datetime import datetime
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tld_price import TLDPrice, TLDInfo
from app.services.tld_scraper.base import TLDPriceData, ScraperError
from app.services.tld_scraper.porkbun import PorkbunScraper
from app.services.tld_scraper.namecheap import NamecheapScraper
from app.services.tld_scraper.cloudflare import CloudflareScraper
from app.services.tld_scraper.godaddy import GoDaddyScraper
from app.services.tld_scraper.dynadot import DynadotScraper
logger = logging.getLogger(__name__)
@dataclass
class ScrapeResult:
"""Result of a scraping run."""
started_at: datetime = field(default_factory=datetime.utcnow)
completed_at: datetime | None = None
status: str = "running" # running, success, partial, failed
sources_attempted: int = 0
sources_succeeded: int = 0
tlds_scraped: int = 0
prices_saved: int = 0
errors: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"status": self.status,
"sources_attempted": self.sources_attempted,
"sources_succeeded": self.sources_succeeded,
"tlds_scraped": self.tlds_scraped,
"prices_saved": self.prices_saved,
"errors": self.errors,
}
class TLDPriceAggregator:
"""
Aggregates TLD prices from multiple sources and saves to database.
Primary source: Porkbun API (most reliable, 896+ TLDs)
Future sources: Can add more scrapers as backup
"""
def __init__(self):
"""Initialize the aggregator with available scrapers.
Scraper priority:
1. Porkbun (API) - Most TLDs, official API
2. GoDaddy (static) - Largest registrar, promo pricing detection
3. Namecheap (static) - Popular alternative
4. Cloudflare (static) - At-cost baseline
5. Dynadot (static) - Competitive pricing reference
"""
self.scrapers = [
PorkbunScraper(), # Primary: 896+ TLDs via official API
GoDaddyScraper(), # Largest registrar, good for promo detection
NamecheapScraper(), # Popular TLDs + budget options
CloudflareScraper(), # At-cost (wholesale) baseline
DynadotScraper(), # Competitive pricing, 80+ TLDs
]
async def run_scrape(self, db: AsyncSession) -> ScrapeResult:
"""
Run a full scrape from all sources and save to database.
Args:
db: Database session
Returns:
ScrapeResult with statistics
"""
result = ScrapeResult()
all_prices: dict[str, TLDPriceData] = {}
# Scrape from all sources
for scraper in self.scrapers:
result.sources_attempted += 1
try:
logger.info(f"Scraping from {scraper.name}...")
prices = await scraper.scrape()
if prices:
result.sources_succeeded += 1
# Store prices (later sources can override earlier ones)
for price in prices:
key = f"{price.tld}_{price.registrar}"
all_prices[key] = price
logger.info(f"Got {len(prices)} prices from {scraper.name}")
else:
result.errors.append(f"{scraper.name}: No data returned")
except ScraperError as e:
error_msg = f"{scraper.name}: {str(e)}"
result.errors.append(error_msg)
logger.error(error_msg)
except Exception as e:
error_msg = f"{scraper.name}: Unexpected error - {str(e)}"
result.errors.append(error_msg)
logger.exception(error_msg)
# Save to database
if all_prices:
result.tlds_scraped = len(set(p.tld for p in all_prices.values()))
result.prices_saved = await self._save_prices(db, list(all_prices.values()))
# Finalize result
result.completed_at = datetime.utcnow()
if result.sources_succeeded == result.sources_attempted:
result.status = "success"
elif result.sources_succeeded > 0:
result.status = "partial"
else:
result.status = "failed"
logger.info(
f"Scrape completed: {result.status}, "
f"{result.tlds_scraped} TLDs, "
f"{result.prices_saved} prices saved"
)
return result
async def _save_prices(self, db: AsyncSession, prices: list[TLDPriceData]) -> int:
"""
Save scraped prices to database.
Args:
db: Database session
prices: List of TLDPriceData to save
Returns:
Number of prices saved
"""
saved_count = 0
# Track TLDs we've already ensured exist (to avoid duplicate inserts)
ensured_tlds: set[str] = set()
for price_data in prices:
try:
# Create new price record (for historical tracking)
price_record = TLDPrice(
tld=price_data.tld,
registrar=price_data.registrar,
registration_price=price_data.registration_price,
renewal_price=price_data.renewal_price,
transfer_price=price_data.transfer_price,
currency=price_data.currency,
promo_price=price_data.promo_price,
recorded_at=price_data.scraped_at,
)
db.add(price_record)
saved_count += 1
# Also update/create TLDInfo if it doesn't exist (only once per TLD)
if price_data.tld not in ensured_tlds:
await self._ensure_tld_info(db, price_data.tld)
ensured_tlds.add(price_data.tld)
except Exception as e:
logger.warning(f"Error saving price for {price_data.tld}: {e}")
continue
await db.commit()
return saved_count
async def _ensure_tld_info(self, db: AsyncSession, tld: str):
"""Ensure TLDInfo record exists for this TLD."""
try:
result = await db.execute(
select(TLDInfo).where(TLDInfo.tld == tld)
)
existing = result.scalar_one_or_none()
if not existing:
# Create basic TLDInfo record
tld_type = self._guess_tld_type(tld)
info = TLDInfo(
tld=tld,
type=tld_type,
)
db.add(info)
await db.flush() # Flush immediately to catch duplicates
except Exception as e:
# Ignore duplicate key errors - TLD already exists
logger.debug(f"TLDInfo for {tld} already exists or error: {e}")
def _guess_tld_type(self, tld: str) -> str:
"""Guess TLD type based on length and pattern."""
# Country codes are typically 2 characters
if len(tld) == 2:
return "ccTLD"
# Common generic TLDs
generic = {"com", "net", "org", "info", "biz", "name", "pro"}
if tld in generic:
return "generic"
# New gTLDs
return "gTLD"
async def get_latest_prices(self, db: AsyncSession, tld: str | None = None) -> list[dict]:
"""
Get latest prices from database.
Args:
db: Database session
tld: Optional TLD to filter by
Returns:
List of price dictionaries
"""
from sqlalchemy import func, desc
# Subquery to get latest record per TLD/registrar combination
subq = (
select(
TLDPrice.tld,
TLDPrice.registrar,
func.max(TLDPrice.recorded_at).label("max_date")
)
.group_by(TLDPrice.tld, TLDPrice.registrar)
.subquery()
)
query = (
select(TLDPrice)
.join(
subq,
(TLDPrice.tld == subq.c.tld) &
(TLDPrice.registrar == subq.c.registrar) &
(TLDPrice.recorded_at == subq.c.max_date)
)
)
if tld:
query = query.where(TLDPrice.tld == tld.lower().lstrip("."))
result = await db.execute(query.order_by(TLDPrice.tld))
prices = result.scalars().all()
return [
{
"tld": p.tld,
"registrar": p.registrar,
"registration_price": p.registration_price,
"renewal_price": p.renewal_price,
"transfer_price": p.transfer_price,
"currency": p.currency,
"promo_price": p.promo_price,
"recorded_at": p.recorded_at.isoformat() if p.recorded_at else None,
}
for p in prices
]
async def get_price_history(
self,
db: AsyncSession,
tld: str,
days: int = 365
) -> list[dict]:
"""
Get price history for a TLD.
Args:
db: Database session
tld: TLD to get history for
days: Number of days of history
Returns:
List of historical price records
"""
from datetime import timedelta
from sqlalchemy import desc
cutoff = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(TLDPrice)
.where(TLDPrice.tld == tld.lower().lstrip("."))
.where(TLDPrice.recorded_at >= cutoff)
.order_by(desc(TLDPrice.recorded_at))
)
prices = result.scalars().all()
return [
{
"tld": p.tld,
"registrar": p.registrar,
"registration_price": p.registration_price,
"renewal_price": p.renewal_price,
"recorded_at": p.recorded_at.isoformat() if p.recorded_at else None,
}
for p in prices
]
# Singleton instance
tld_aggregator = TLDPriceAggregator()