pounce/backend/app/api/yield_routing.py
yves.gugger 58228e3d33
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: integrate Pounce self-promotion & viral growth system
Pounce Eigenwerbung (from pounce_endgame.md):
- Add 'pounce_promo' as fallback partner for generic/unclear intent domains
- Create dedicated Pounce promo landing page with CTA to register
- Update footer on all yield pages: 'Monetized by Pounce • Own a domain? Start yielding'

Tech/Investment Domain Detection:
- Add 'investment_domains' category (invest, crypto, trading, domain, startup)
- Add 'tech_dev' category (developer, web3, fintech, proptech)
- Both categories have 'pounce_affinity' flag for higher Pounce conversion

Referral Tracking for Domain Owners:
- Add user fields: referred_by_user_id, referred_by_domain, referral_code
- Parse yield referral codes (yield_{user_id}_{domain_id}) on registration
- Domain owners earn lifetime commission when visitors sign up via their domain

DB Migrations:
- Add referral tracking columns to users table
2025-12-12 15:27:53 +01:00

603 lines
19 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]
# Pounce self-promotion fallback with referral tracking
# Domain owner gets lifetime commission on signups via their domain
referral_code = f"yield_{yield_domain.user_id}_{yield_domain.id}"
return f"{settings.site_url}/register?ref={referral_code}&from={yield_domain.domain}&clickid={click_id}"
def is_pounce_affinity_domain(domain: str) -> bool:
"""
Check if a domain has high affinity for Pounce self-promotion.
Tech, investment, and domain-related domains convert better for Pounce.
"""
intent = detect_domain_intent(domain)
# Check if the matched category has pounce_affinity flag
if intent.category in ["investment", "tech"] or intent.subcategory in ["domains", "dev"]:
return True
# Check for specific keywords
pounce_keywords = {
"invest", "domain", "trading", "crypto", "asset", "portfolio",
"startup", "tech", "dev", "saas", "digital", "passive", "income"
}
domain_lower = domain.lower()
return any(kw in domain_lower for kw in pounce_keywords)
def generate_pounce_promo_page(
yield_domain: YieldDomain,
click_id: int,
) -> str:
"""
Generate Pounce self-promotion landing page.
Used as fallback when no high-value partner is available,
or when the domain has high Pounce affinity.
"""
referral_code = f"yield_{yield_domain.user_id}_{yield_domain.id}"
register_url = f"{settings.site_url}/register?ref={referral_code}&from={yield_domain.domain}&clickid={click_id}"
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{yield_domain.domain} - Powered by Pounce</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
min-height: 100vh;
display: flex;
flex-direction: column;
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;
padding: 2rem;
}}
.container {{
text-align: center;
max-width: 600px;
}}
.badge {{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 9999px;
color: #10b981;
font-size: 0.875rem;
margin-bottom: 2rem;
}}
.domain {{
font-size: 1.25rem;
color: #6b7280;
margin-bottom: 1rem;
}}
h1 {{
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 1.5rem;
}}
h1 span {{
background: linear-gradient(90deg, #10b981, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.subtitle {{
font-size: 1.125rem;
color: #9ca3af;
margin-bottom: 2.5rem;
line-height: 1.6;
}}
.cta {{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
background: linear-gradient(90deg, #10b981, #059669);
color: #fff;
font-size: 1.125rem;
font-weight: 600;
border-radius: 0.75rem;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.3);
}}
.cta:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 30px rgba(16, 185, 129, 0.4);
}}
.features {{
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 3rem;
flex-wrap: wrap;
}}
.feature {{
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}}
.feature svg {{
color: #10b981;
}}
.footer {{
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #4b5563;
}}
.footer a {{
color: #10b981;
text-decoration: none;
}}
.owner-note {{
margin-top: 3rem;
padding: 1rem;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 0.5rem;
font-size: 0.875rem;
color: #a78bfa;
}}
</style>
</head>
<body>
<div class="container">
<div class="badge">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
This domain is monetized by Pounce
</div>
<div class="domain">{yield_domain.domain}</div>
<h1>
Turn Your Domains Into<br>
<span>Passive Income</span>
</h1>
<p class="subtitle">
Stop paying renewal fees for idle domains.<br>
Let them earn money for you — automatically.
</p>
<a href="{register_url}" class="cta">
Start Earning Free
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<div class="features">
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
Free Forever
</div>
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
70% Revenue Share
</div>
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Swiss Quality
</div>
</div>
<div class="owner-note">
👋 The owner of this domain earns a commission when you sign up!
</div>
</div>
<div class="footer">
<a href="{settings.site_url}">pounce.ch</a> — Domain Intelligence Platform
</div>
</body>
</html>
"""
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
If no partner, shows Pounce self-promotion instead.
"""
# If no partner or partner is pounce_promo, show Pounce promo page
if partner is None or partner.slug == "pounce_promo":
return generate_pounce_promo_page(yield_domain, click_id)
intent = detect_domain_intent(yield_domain.domain)
# Partner info
partner_name = partner.name
partner_desc = partner.description or "Find the best offers"
# Generate redirect URL
redirect_url = generate_tracking_url(partner, yield_domain, click_id)
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">
Monetized by <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Pounce</a> •
Own a domain? <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Start yielding →</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
)