pounce/backend/app/services/domain_checker.py
Yves Gugger 9acb90c067 Initial commit: Pounce - Domain Monitoring System
- FastAPI backend mit Domain-Check, TLD-Pricing, User-Management
- Next.js frontend mit modernem UI
- Sortierbare TLD-Tabelle mit Mini-Charts
- Domain availability monitoring
- Subscription tiers (Starter, Professional, Enterprise)
- Authentication & Authorization
- Scheduler für automatische Domain-Checks
2025-12-08 07:26:57 +01:00

492 lines
17 KiB
Python

"""
Advanced Domain Availability Checker
Uses multiple methods for maximum accuracy:
1. RDAP (Registration Data Access Protocol) - Modern, accurate, JSON format
2. DNS lookup - Fast availability check
3. WHOIS - Fallback for TLDs without RDAP
Performance optimized with caching and async operations.
"""
import asyncio
import logging
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import Optional
from functools import lru_cache
import dns.resolver
import whois
import whodap
from app.models.domain import DomainStatus
logger = logging.getLogger(__name__)
@dataclass
class DomainCheckResult:
"""Result of a domain availability check."""
domain: str
status: DomainStatus
is_available: bool
registrar: Optional[str] = None
expiration_date: Optional[datetime] = None
creation_date: Optional[datetime] = None
updated_date: Optional[datetime] = None
name_servers: Optional[list[str]] = None
error_message: Optional[str] = None
check_method: str = "unknown" # rdap, whois, dns
raw_data: Optional[dict] = None
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"domain": self.domain,
"status": self.status.value,
"is_available": self.is_available,
"registrar": self.registrar,
"expiration_date": self.expiration_date.isoformat() if self.expiration_date else None,
"creation_date": self.creation_date.isoformat() if self.creation_date else None,
"updated_date": self.updated_date.isoformat() if self.updated_date else None,
"name_servers": self.name_servers,
"error_message": self.error_message,
"check_method": self.check_method,
}
class DomainChecker:
"""
Advanced domain availability checker.
Priority: RDAP > DNS > WHOIS
"""
# TLDs known to support RDAP
RDAP_SUPPORTED_TLDS = {
'com', 'net', 'org', 'info', 'biz', 'mobi', 'name', 'pro',
'app', 'dev', 'page', 'new', 'day', 'eat', 'fly', 'how',
'io', 'co', 'ai', 'me', 'tv', 'cc', 'ws',
'xyz', 'top', 'site', 'online', 'tech', 'store', 'club',
'de', 'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us',
}
# TLDs that only support WHOIS (no RDAP)
WHOIS_ONLY_TLDS = {
'ch', 'li', 'ru', 'su', 'ua', 'by', 'kz',
}
def __init__(self):
"""Initialize the domain checker."""
self._dns_resolver = dns.resolver.Resolver()
self._dns_resolver.timeout = 3
self._dns_resolver.lifetime = 5
self._cache = {}
self._cache_ttl = 300 # 5 minutes
def _normalize_domain(self, domain: str) -> str:
"""Normalize domain name."""
domain = domain.lower().strip()
if domain.startswith('http://'):
domain = domain[7:]
elif domain.startswith('https://'):
domain = domain[8:]
if domain.startswith('www.'):
domain = domain[4:]
domain = domain.split('/')[0]
return domain
def _get_tld(self, domain: str) -> str:
"""Extract TLD from domain."""
parts = domain.split('.')
return parts[-1].lower() if parts else ''
def _get_sld(self, domain: str) -> str:
"""Extract second-level domain (without TLD)."""
parts = domain.split('.')
return parts[0] if parts else domain
def _parse_datetime(self, date_str: str) -> Optional[datetime]:
"""Parse various datetime formats."""
if not date_str:
return None
# Common formats
formats = [
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
"%Y-%m-%d",
]
for fmt in formats:
try:
return datetime.strptime(date_str.replace('+00:00', 'Z').replace('.000Z', 'Z'), fmt)
except ValueError:
continue
# Try ISO format
try:
# Handle formats like "2028-09-14T07:00:00.000+00:00"
if '+' in date_str:
date_str = date_str.split('+')[0] + '+00:00'
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
pass
return None
async def _check_rdap(self, domain: str) -> Optional[DomainCheckResult]:
"""
Check domain using RDAP (Registration Data Access Protocol).
Returns None if RDAP is not available for this TLD.
"""
tld = self._get_tld(domain)
sld = self._get_sld(domain)
try:
# Run RDAP lookup in thread pool
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: whodap.lookup_domain(sld, tld)
)
# Parse events for dates
expiration_date = None
creation_date = None
updated_date = None
registrar = None
if response.events:
for event in response.events:
# Access event data from __dict__
event_dict = event.__dict__ if hasattr(event, '__dict__') else {}
action = event_dict.get('eventAction', '')
date_str = event_dict.get('eventDate', '')
if not action or not date_str:
continue
action_lower = action.lower()
if 'expiration' in action_lower and not expiration_date:
expiration_date = self._parse_datetime(date_str)
elif 'registration' in action_lower and not creation_date:
creation_date = self._parse_datetime(date_str)
elif 'changed' in action_lower and 'database' not in action_lower:
updated_date = self._parse_datetime(date_str)
# Extract registrar from entities
if response.entities:
for entity in response.entities:
try:
entity_dict = entity.__dict__ if hasattr(entity, '__dict__') else {}
roles = entity_dict.get('roles', [])
if 'registrar' in roles:
vcard = entity_dict.get('vcardArray', [])
if isinstance(vcard, list) and len(vcard) > 1:
for item in vcard[1]:
if isinstance(item, list) and len(item) > 3:
if item[0] == 'fn' and item[3]:
registrar = str(item[3])
break
elif item[0] == 'org' and item[3]:
registrar = str(item[3])
except Exception:
continue
return DomainCheckResult(
domain=domain,
status=DomainStatus.TAKEN,
is_available=False,
registrar=registrar,
expiration_date=expiration_date,
creation_date=creation_date,
updated_date=updated_date,
check_method="rdap",
)
except NotImplementedError:
# No RDAP server for this TLD
logger.debug(f"No RDAP server for TLD .{tld}")
return None
except Exception as e:
error_msg = str(e).lower()
# Check if domain is not found (available)
if 'not found' in error_msg or '404' in error_msg:
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE,
is_available=True,
check_method="rdap",
)
logger.warning(f"RDAP check failed for {domain}: {e}")
return None
async def _check_whois(self, domain: str) -> DomainCheckResult:
"""Check domain using WHOIS (fallback method)."""
try:
loop = asyncio.get_event_loop()
w = await loop.run_in_executor(None, whois.whois, domain)
# Check if domain is available
# 1. No domain_name returned
if w.domain_name is None:
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE,
is_available=True,
check_method="whois",
)
# 2. Check the raw text for "not found" indicators
raw_text = str(w.text).lower() if hasattr(w, 'text') and w.text else ""
not_found_indicators = [
'no match',
'not found',
'no entries',
'no data',
'status: free',
'no entry',
'we do not have an entry',
'domain not found',
'is available',
'available for registration',
'no object found',
'object does not exist',
]
if any(indicator in raw_text for indicator in not_found_indicators):
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE,
is_available=True,
check_method="whois",
)
# 3. Check if no registrar and no creation date (likely available)
if not w.registrar and not w.creation_date and not w.name_servers:
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE,
is_available=True,
check_method="whois",
)
# Extract data
expiration = None
creation = None
registrar = None
name_servers = None
if w.expiration_date:
if isinstance(w.expiration_date, list):
expiration = w.expiration_date[0]
else:
expiration = w.expiration_date
if w.creation_date:
if isinstance(w.creation_date, list):
creation = w.creation_date[0]
else:
creation = w.creation_date
if w.registrar:
registrar = w.registrar if isinstance(w.registrar, str) else str(w.registrar)
if w.name_servers:
if isinstance(w.name_servers, list):
name_servers = [str(ns).lower() for ns in w.name_servers]
else:
name_servers = [str(w.name_servers).lower()]
return DomainCheckResult(
domain=domain,
status=DomainStatus.TAKEN,
is_available=False,
registrar=registrar,
expiration_date=expiration,
creation_date=creation,
name_servers=name_servers,
check_method="whois",
)
except Exception as e:
# Check if it's a "domain not found" type error (indicates available)
error_str = str(e).lower()
not_found_phrases = [
'no match',
'not found',
'no entries',
'no data',
'status: free',
'no entry',
'we do not have an entry',
'domain not found',
'is available',
'no object found',
'object does not exist',
]
if any(phrase in error_str for phrase in not_found_phrases):
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE,
is_available=True,
check_method="whois",
)
# Otherwise it's a real error
return DomainCheckResult(
domain=domain,
status=DomainStatus.ERROR,
is_available=False,
error_message=str(e),
check_method="whois",
)
async def _check_dns(self, domain: str) -> bool:
"""
Quick DNS check for domain existence.
Returns True if domain appears available (no DNS records).
"""
try:
loop = asyncio.get_event_loop()
# Try A record
try:
await loop.run_in_executor(
None,
lambda: self._dns_resolver.resolve(domain, 'A')
)
return False # Has A record = taken
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
pass
# Try NS record
try:
await loop.run_in_executor(
None,
lambda: self._dns_resolver.resolve(domain, 'NS')
)
return False # Has NS record = taken
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
pass
return True # No DNS records = likely available
except Exception:
return True # On error, assume might be available
async def check_domain(self, domain: str, quick: bool = False) -> DomainCheckResult:
"""
Check domain availability using the best available method.
Priority:
1. RDAP (most accurate, modern protocol)
2. WHOIS (fallback for TLDs without RDAP)
3. DNS (quick check only)
Args:
domain: Domain name to check
quick: If True, only use DNS (faster but less accurate)
Returns:
DomainCheckResult with detailed availability info
"""
domain = self._normalize_domain(domain)
if not domain or '.' not in domain:
return DomainCheckResult(
domain=domain,
status=DomainStatus.ERROR,
is_available=False,
error_message="Invalid domain format",
)
tld = self._get_tld(domain)
# Quick DNS check
if quick:
dns_available = await self._check_dns(domain)
return DomainCheckResult(
domain=domain,
status=DomainStatus.AVAILABLE if dns_available else DomainStatus.TAKEN,
is_available=dns_available,
check_method="dns",
)
# Try RDAP first (best accuracy)
if tld not in self.WHOIS_ONLY_TLDS:
rdap_result = await self._check_rdap(domain)
if rdap_result:
# Validate with DNS if RDAP says available
if rdap_result.is_available:
dns_available = await self._check_dns(domain)
if not dns_available:
rdap_result.status = DomainStatus.TAKEN
rdap_result.is_available = False
return rdap_result
# Fall back to WHOIS
whois_result = await self._check_whois(domain)
# Validate with DNS
if whois_result.is_available:
dns_available = await self._check_dns(domain)
if not dns_available:
whois_result.status = DomainStatus.TAKEN
whois_result.is_available = False
return whois_result
async def check_multiple(self, domains: list[str], quick: bool = False) -> list[DomainCheckResult]:
"""
Check multiple domains concurrently.
Args:
domains: List of domain names
quick: Use quick DNS-only check
Returns:
List of DomainCheckResult
"""
tasks = [self.check_domain(d, quick=quick) for d in domains]
return await asyncio.gather(*tasks)
def validate_domain(self, domain: str) -> tuple[bool, str]:
"""
Validate domain format.
Returns:
Tuple of (is_valid, error_message)
"""
domain = self._normalize_domain(domain)
if not domain:
return False, "Domain cannot be empty"
if '.' not in domain:
return False, "Domain must include TLD (e.g., .com)"
parts = domain.split('.')
for part in parts:
if not part:
return False, "Invalid domain format"
if len(part) > 63:
return False, "Domain label too long (max 63 characters)"
if not all(c.isalnum() or c == '-' for c in part):
return False, "Domain contains invalid characters"
if part.startswith('-') or part.endswith('-'):
return False, "Domain labels cannot start or end with hyphen"
if len(domain) > 253:
return False, "Domain name too long (max 253 characters)"
return True, ""
# Singleton instance
domain_checker = DomainChecker()