pounce/backend/app/services/dropcatch_api.py
yves.gugger 389379d8bb feat: DropCatch & Sedo API Clients + MARKET_CONCEPT v2
- DropCatch API Client mit OAuth2 Authentifizierung
- Sedo API Client (bereit für Credentials)
- Tier 1 APIs → Tier 2 Scraping Fallback-Logik
- Admin Endpoints: /test-apis, /trigger-scrape, /scrape-status
- MARKET_CONCEPT.md komplett überarbeitet:
  - Realistische Bestandsaufnahme
  - 3-Säulen-Konzept (Auktionen, Pounce Direct, Drops)
  - API-Realität dokumentiert (DropCatch = nur eigene Aktivitäten)
  - Roadmap und nächste Schritte
2025-12-11 09:36:32 +01:00

335 lines
11 KiB
Python

"""
DropCatch Official API Client
This service provides access to DropCatch's official API for:
- Searching domain auctions
- Getting auction details
- Backorder management
API Documentation: https://www.dropcatch.com/hiw/dropcatch-api
Interactive Docs: https://api.dropcatch.com/swagger
SECURITY:
- Credentials are loaded from environment variables
- NEVER hardcode credentials in this file
Usage:
from app.services.dropcatch_api import dropcatch_client
# Get active auctions
auctions = await dropcatch_client.search_auctions(keyword="tech")
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from functools import lru_cache
from app.config import get_settings
logger = logging.getLogger(__name__)
class DropCatchAPIClient:
"""
Official DropCatch API Client.
This uses the V2 API endpoints (V1 is deprecated).
Authentication is via OAuth2 client credentials.
"""
def __init__(self):
self.settings = get_settings()
self.base_url = self.settings.dropcatch_api_base or "https://api.dropcatch.com"
self.client_id = self.settings.dropcatch_client_id
self.client_secret = self.settings.dropcatch_client_secret
# Token cache
self._access_token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
# HTTP client
self._client: Optional[httpx.AsyncClient] = None
@property
def is_configured(self) -> bool:
"""Check if API credentials are configured."""
return bool(self.client_id and self.client_secret)
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=30.0,
headers={
"Content-Type": "application/json",
"User-Agent": "Pounce/1.0 (Domain Intelligence Platform)"
}
)
return self._client
async def close(self):
"""Close the HTTP client."""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _authenticate(self) -> str:
"""
Authenticate with DropCatch API and get access token.
POST https://api.dropcatch.com/authorize
Body: { "clientId": "...", "clientSecret": "..." }
Returns: Access token string
"""
if not self.is_configured:
raise ValueError("DropCatch API credentials not configured")
# Check if we have a valid cached token
if self._access_token and self._token_expires_at:
if datetime.utcnow() < self._token_expires_at - timedelta(minutes=5):
return self._access_token
client = await self._get_client()
try:
response = await client.post(
f"{self.base_url}/authorize",
json={
"clientId": self.client_id,
"clientSecret": self.client_secret
}
)
if response.status_code != 200:
logger.error(f"DropCatch auth failed: {response.status_code} - {response.text}")
raise Exception(f"Authentication failed: {response.status_code}")
data = response.json()
# Extract token - the response format may vary
# Common formats: { "token": "...", "expiresIn": 3600 }
# or: { "accessToken": "...", "expiresIn": 3600 }
self._access_token = data.get("token") or data.get("accessToken") or data.get("access_token")
# Calculate expiry (default 1 hour if not specified)
expires_in = data.get("expiresIn") or data.get("expires_in") or 3600
self._token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
logger.info("DropCatch API: Successfully authenticated")
return self._access_token
except httpx.HTTPError as e:
logger.error(f"DropCatch auth HTTP error: {e}")
raise
async def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None
) -> Dict[str, Any]:
"""Make an authenticated API request."""
token = await self._authenticate()
client = await self._get_client()
headers = {
"Authorization": f"Bearer {token}"
}
url = f"{self.base_url}{endpoint}"
try:
response = await client.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers
)
if response.status_code == 401:
# Token expired, re-authenticate
self._access_token = None
token = await self._authenticate()
headers["Authorization"] = f"Bearer {token}"
response = await client.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"DropCatch API request failed: {e}")
raise
# =========================================================================
# AUCTION ENDPOINTS (V2)
# =========================================================================
async def search_auctions(
self,
keyword: Optional[str] = None,
tld: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
ending_within_hours: Optional[int] = None,
page_size: int = 100,
page_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
Search for domain auctions.
Endpoint: GET /v2/auctions (or similar - check interactive docs)
Returns:
{
"auctions": [...],
"cursor": {
"next": "...",
"previous": "..."
}
}
"""
params = {
"pageSize": page_size,
}
if keyword:
params["searchTerm"] = keyword
if tld:
params["tld"] = tld.lstrip(".")
if min_price is not None:
params["minPrice"] = min_price
if max_price is not None:
params["maxPrice"] = max_price
if ending_within_hours:
params["endingWithinHours"] = ending_within_hours
if page_token:
params["pageToken"] = page_token
return await self._request("GET", "/v2/auctions", params=params)
async def get_auction(self, auction_id: int) -> Dict[str, Any]:
"""Get details for a specific auction."""
return await self._request("GET", f"/v2/auctions/{auction_id}")
async def get_ending_soon(
self,
hours: int = 24,
page_size: int = 50
) -> Dict[str, Any]:
"""Get auctions ending soon."""
return await self.search_auctions(
ending_within_hours=hours,
page_size=page_size
)
async def get_hot_auctions(self, page_size: int = 50) -> Dict[str, Any]:
"""
Get hot/popular auctions (high bid activity).
Note: The actual endpoint may vary - check interactive docs.
"""
# This might be a different endpoint or sort parameter
params = {
"pageSize": page_size,
"sortBy": "bidCount", # or "popularity" - check docs
"sortOrder": "desc"
}
return await self._request("GET", "/v2/auctions", params=params)
# =========================================================================
# BACKORDER ENDPOINTS (V2)
# =========================================================================
async def search_backorders(
self,
keyword: Optional[str] = None,
page_size: int = 100,
page_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Search for available backorders (domains dropping soon)."""
params = {"pageSize": page_size}
if keyword:
params["searchTerm"] = keyword
if page_token:
params["pageToken"] = page_token
return await self._request("GET", "/v2/backorders", params=params)
# =========================================================================
# UTILITY METHODS
# =========================================================================
async def test_connection(self) -> Dict[str, Any]:
"""Test the API connection and credentials."""
if not self.is_configured:
return {
"success": False,
"error": "API credentials not configured",
"configured": False
}
try:
await self._authenticate()
return {
"success": True,
"configured": True,
"client_id": self.client_id.split(":")[0] if ":" in self.client_id else self.client_id,
"authenticated_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": str(e),
"configured": True
}
def transform_to_pounce_format(self, dc_auction: Dict) -> Dict[str, Any]:
"""
Transform DropCatch auction to Pounce internal format.
Maps DropCatch fields to our DomainAuction model.
"""
domain = dc_auction.get("domainName") or dc_auction.get("domain", "")
tld = domain.rsplit(".", 1)[1] if "." in domain else ""
# Parse end time (format may vary)
end_time_str = dc_auction.get("auctionEndTime") or dc_auction.get("endTime")
if end_time_str:
try:
end_time = datetime.fromisoformat(end_time_str.replace("Z", "+00:00"))
except:
end_time = datetime.utcnow() + timedelta(days=1)
else:
end_time = datetime.utcnow() + timedelta(days=1)
return {
"domain": domain,
"tld": tld,
"platform": "DropCatch",
"current_bid": dc_auction.get("currentBid") or dc_auction.get("price", 0),
"currency": "USD",
"num_bids": dc_auction.get("bidCount") or dc_auction.get("numberOfBids", 0),
"end_time": end_time,
"auction_url": f"https://www.dropcatch.com/domain/{domain}",
"age_years": dc_auction.get("yearsOld") or dc_auction.get("age"),
"buy_now_price": dc_auction.get("buyNowPrice"),
"reserve_met": dc_auction.get("reserveMet"),
"traffic": dc_auction.get("traffic"),
"external_id": str(dc_auction.get("auctionId") or dc_auction.get("id", "")),
}
# Singleton instance
dropcatch_client = DropCatchAPIClient()