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
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
603 lines
19 KiB
Python
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
|
|
)
|
|
|