Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
## 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
313 lines
11 KiB
Python
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()
|
|
|