pounce/backend/app/api/yield_routing.py
yves.gugger 1705b5cc6e
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
feat: complete Yield feature setup
Backend:
- Add yield_webhooks.py for partner callbacks (generic, Awin, batch import)
- Add yield_routing.py for domain traffic routing with landing pages
- Add DB migrations for yield table indexes
- Add seed script with 30+ Swiss/German affiliate partners
- Register all new routers in API

Frontend:
- Add public /yield landing page with live analyzer demo
- Add Yield to header navigation

Documentation:
- Complete YIELD_SETUP.md with setup guide, API reference, and troubleshooting
2025-12-12 14:52:49 +01:00

382 lines
12 KiB
Python

"""
Yield Domain Routing API.
This handles incoming HTTP requests to yield domains:
1. Detect the domain from the Host header
2. Look up the yield configuration
3. Track the click
4. Redirect to the appropriate affiliate landing page
In production, this runs on a separate subdomain or IP (yield.pounce.io)
that yield domains CNAME to.
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Request, Response, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.config import get_settings
from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
from app.services.intent_detector import detect_domain_intent
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter(prefix="/r", tags=["yield-routing"])
# Revenue split
USER_REVENUE_SHARE = 0.70
def hash_ip(ip: str) -> str:
"""Hash IP for privacy-compliant storage."""
import hashlib
return hashlib.sha256(ip.encode()).hexdigest()[:32]
def generate_tracking_url(
partner: AffiliatePartner,
yield_domain: YieldDomain,
click_id: int,
) -> str:
"""
Generate the tracking URL for a partner.
Most affiliate networks expect parameters like:
- clickid / subid: Our click tracking ID
- ref: Domain name or user reference
"""
# If partner has a tracking URL template, use it
if partner.tracking_url_template:
return partner.tracking_url_template.format(
click_id=click_id,
domain=yield_domain.domain,
domain_id=yield_domain.id,
partner=partner.slug,
)
# Default fallbacks by network
network_urls = {
"comparis_dental": f"https://www.comparis.ch/zahnarzt?subid={click_id}&ref={yield_domain.domain}",
"comparis_health": f"https://www.comparis.ch/krankenkassen?subid={click_id}&ref={yield_domain.domain}",
"comparis_insurance": f"https://www.comparis.ch/versicherungen?subid={click_id}&ref={yield_domain.domain}",
"comparis_hypo": f"https://www.comparis.ch/hypotheken?subid={click_id}&ref={yield_domain.domain}",
"comparis_auto": f"https://www.comparis.ch/autoversicherung?subid={click_id}&ref={yield_domain.domain}",
"comparis_immo": f"https://www.comparis.ch/immobilien?subid={click_id}&ref={yield_domain.domain}",
"homegate": f"https://www.homegate.ch/?ref=pounce&clickid={click_id}",
"immoscout": f"https://www.immoscout24.ch/?ref=pounce&clickid={click_id}",
"autoscout": f"https://www.autoscout24.ch/?ref=pounce&clickid={click_id}",
"jobs_ch": f"https://www.jobs.ch/?ref=pounce&clickid={click_id}",
"booking_com": f"https://www.booking.com/?aid=pounce&clickid={click_id}",
"hostpoint": f"https://www.hostpoint.ch/?ref=pounce&clickid={click_id}",
"infomaniak": f"https://www.infomaniak.com/?ref=pounce&clickid={click_id}",
"galaxus": f"https://www.galaxus.ch/?ref=pounce&clickid={click_id}",
"zalando": f"https://www.zalando.ch/?ref=pounce&clickid={click_id}",
}
if partner.slug in network_urls:
return network_urls[partner.slug]
# Generic fallback - show Pounce marketplace
return f"{settings.site_url}/buy?ref={yield_domain.domain}&clickid={click_id}"
def generate_landing_page(
yield_domain: YieldDomain,
partner: Optional[AffiliatePartner],
click_id: int,
) -> str:
"""
Generate an interstitial landing page.
Shows for a moment before redirecting, to:
1. Improve user experience
2. Allow for A/B testing
3. Comply with affiliate disclosure requirements
"""
intent = detect_domain_intent(yield_domain.domain)
# Partner info
partner_name = partner.name if partner else "Partner"
partner_desc = partner.description if partner else "Find the best offers"
# Generate redirect URL
redirect_url = (
generate_tracking_url(partner, yield_domain, click_id)
if partner else f"{settings.site_url}/buy"
)
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="3;url={redirect_url}">
<title>{yield_domain.domain} - Redirecting</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
color: #fff;
}}
.container {{
text-align: center;
padding: 2rem;
max-width: 500px;
}}
.domain {{
font-size: 1.5rem;
font-weight: 700;
color: #10b981;
margin-bottom: 0.5rem;
}}
.intent {{
font-size: 0.875rem;
color: #6b7280;
text-transform: capitalize;
margin-bottom: 2rem;
}}
.message {{
font-size: 1.125rem;
color: #d1d5db;
margin-bottom: 1rem;
}}
.partner {{
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 2rem;
}}
.spinner {{
width: 40px;
height: 40px;
border: 3px solid #1f2937;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1.5rem;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.link {{
color: #10b981;
text-decoration: none;
}}
.link:hover {{
text-decoration: underline;
}}
.footer {{
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #4b5563;
}}
.footer a {{
color: #6b7280;
text-decoration: none;
}}
</style>
</head>
<body>
<div class="container">
<div class="domain">{yield_domain.domain}</div>
<div class="intent">{intent.category.replace('_', ' ')}</div>
<div class="spinner"></div>
<div class="message">Redirecting to {partner_name}...</div>
<div class="partner">{partner_desc}</div>
<p>
<a href="{redirect_url}" class="link">Click here if not redirected</a>
</p>
</div>
<div class="footer">
Powered by <a href="{settings.site_url}">Pounce</a> •
<a href="{settings.site_url}/privacy">Privacy</a>
</div>
<script>
// Redirect after 2 seconds
setTimeout(function() {{
window.location.href = "{redirect_url}";
}}, 2000);
</script>
</body>
</html>
"""
@router.get("/{domain}")
async def route_yield_domain(
domain: str,
request: Request,
db: Session = Depends(get_db),
direct: bool = False, # Skip landing page if true
):
"""
Route traffic for a yield domain.
This is the main entry point for yield domain traffic.
Query params:
- direct: If true, redirect immediately without landing page
"""
domain = domain.lower().strip()
# Find yield domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == domain,
YieldDomain.status == "active",
).first()
if not yield_domain:
# Domain not found or not active - show error page
logger.warning(f"Route request for unknown/inactive domain: {domain}")
return HTMLResponse(
content=f"""
<html>
<head><title>Domain Not Active</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Domain Not Active</h1>
<p>The domain <strong>{domain}</strong> is not currently active for yield routing.</p>
<p><a href="{settings.site_url}">Visit Pounce</a></p>
</body>
</html>
""",
status_code=404
)
# Get partner
partner = None
if yield_domain.partner_id:
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.id == yield_domain.partner_id,
AffiliatePartner.is_active == True,
).first()
# If no partner assigned, try to find one based on intent
if not partner and yield_domain.detected_intent:
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.intent_categories.contains(yield_domain.detected_intent.split('_')[0]),
AffiliatePartner.is_active == True,
).order_by(AffiliatePartner.priority.desc()).first()
# Create click transaction
client_ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
referrer = request.headers.get("referer")
transaction = YieldTransaction(
yield_domain_id=yield_domain.id,
event_type="click",
partner_slug=partner.slug if partner else "unknown",
gross_amount=0,
net_amount=0,
currency="CHF",
referrer=referrer,
user_agent=user_agent[:500] if user_agent else None,
ip_hash=hash_ip(client_ip) if client_ip else None,
status="confirmed",
confirmed_at=datetime.utcnow(),
)
db.add(transaction)
# Update domain stats
yield_domain.total_clicks += 1
yield_domain.last_click_at = datetime.utcnow()
db.commit()
db.refresh(transaction)
# Generate redirect URL
redirect_url = (
generate_tracking_url(partner, yield_domain, transaction.id)
if partner else f"{settings.site_url}/buy?ref={domain}"
)
# Direct redirect or show landing page
if direct:
return RedirectResponse(url=redirect_url, status_code=302)
# Show interstitial landing page
html = generate_landing_page(yield_domain, partner, transaction.id)
return HTMLResponse(content=html)
@router.get("/")
async def yield_routing_info():
"""Info endpoint for yield routing service."""
return {
"service": "Pounce Yield Routing",
"version": "1.0.0",
"docs": f"{settings.site_url}/docs#/yield-routing",
"status": "active",
}
# ============================================================================
# Host-based routing (for production deployment)
# ============================================================================
@router.api_route("/catch-all", methods=["GET", "HEAD"])
async def catch_all_route(
request: Request,
db: Session = Depends(get_db),
):
"""
Catch-all route for host-based routing.
In production, this endpoint handles requests where the Host header
is the yield domain itself (e.g., zahnarzt-zuerich.ch).
This requires:
1. Yield domains to CNAME to yield.pounce.io
2. Nginx/Caddy to route all hosts to this backend
3. This endpoint to parse the Host header
"""
host = request.headers.get("host", "").lower()
# Remove port if present
if ":" in host:
host = host.split(":")[0]
# Skip our own domains
our_domains = ["pounce.ch", "pounce.io", "localhost", "127.0.0.1"]
if any(host.endswith(d) for d in our_domains):
return {"status": "not a yield domain", "host": host}
# Look up yield domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == host,
YieldDomain.status == "active",
).first()
if not yield_domain:
return HTMLResponse(
content=f"<h1>Domain {host} not configured</h1>",
status_code=404
)
# Redirect to routing endpoint
return RedirectResponse(
url=f"/api/v1/r/{host}",
status_code=302
)