"""HUNT (Discovery) endpoints.""" from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, Query, Request from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.database import get_db from app.models.auction import DomainAuction from app.models.user import User from app.schemas.hunt import ( BrandableRequest, BrandableResponse, HuntSniperItem, HuntSniperResponse, KeywordAvailabilityRequest, KeywordAvailabilityResponse, KeywordAvailabilityRow, TrendsResponse, TrendItem, TypoCheckRequest, TypoCheckResponse, TypoCandidate, ) from app.services.domain_checker import domain_checker from app.services.hunt.brandables import check_domains, generate_cvcvc, generate_cvccv, generate_human from app.services.hunt.trends import fetch_google_trends_daily_rss from app.services.hunt.typos import generate_typos router = APIRouter() limiter = Limiter(key_func=get_remote_address) def _utcnow() -> datetime: return datetime.now(timezone.utc) @router.get("/bargain-bin", response_model=HuntSniperResponse) @limiter.limit("60/minute") async def bargain_bin( request: Request, _user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), limit: int = Query(100, ge=1, le=500), ): """ Closeout Sniper (Chris logic): price < $10 AND age_years >= 5 AND backlinks > 0 Uses ONLY real scraped auction data (DomainAuction.age_years/backlinks). Items without required fields are excluded. """ now = _utcnow().replace(tzinfo=None) base = and_(DomainAuction.is_active == True, DomainAuction.end_time > now) # noqa: E712 rows = ( await db.execute( select(DomainAuction) .where(base) .where(DomainAuction.current_bid < 10) .order_by(DomainAuction.end_time.asc()) .limit(limit * 3) # allow filtering ) ).scalars().all() filtered_out = 0 items: list[HuntSniperItem] = [] for a in rows: if a.age_years is None or a.backlinks is None: filtered_out += 1 continue if int(a.age_years) < 5 or int(a.backlinks) <= 0: continue items.append( HuntSniperItem( domain=a.domain, platform=a.platform, auction_url=a.auction_url, current_bid=float(a.current_bid), currency=a.currency, end_time=a.end_time.replace(tzinfo=timezone.utc) if a.end_time and a.end_time.tzinfo is None else a.end_time, age_years=int(a.age_years) if a.age_years is not None else None, backlinks=int(a.backlinks) if a.backlinks is not None else None, pounce_score=int(a.pounce_score) if a.pounce_score is not None else None, ) ) if len(items) >= limit: break last_updated = ( await db.execute(select(func.max(DomainAuction.updated_at)).where(DomainAuction.is_active == True)) # noqa: E712 ).scalar() return HuntSniperResponse( items=items, total=len(items), filtered_out_missing_data=int(filtered_out), last_updated=last_updated.replace(tzinfo=timezone.utc) if last_updated and last_updated.tzinfo is None else last_updated, ) @router.get("/trends", response_model=TrendsResponse) @limiter.limit("30/minute") async def trends( request: Request, _user: User = Depends(get_current_user), geo: str = Query("US", min_length=2, max_length=2), ): items_raw = await fetch_google_trends_daily_rss(geo=geo) items = [ TrendItem( title=i["title"], approx_traffic=i.get("approx_traffic"), published_at=i.get("published_at"), link=i.get("link"), ) for i in items_raw[:50] ] return TrendsResponse(geo=geo.upper(), items=items, fetched_at=_utcnow()) @router.post("/keywords", response_model=KeywordAvailabilityResponse) @limiter.limit("30/minute") async def keyword_availability( request: Request, _user: User = Depends(get_current_user), payload: KeywordAvailabilityRequest, ): # Normalize + cap work for UX/perf keywords = [] for kw in payload.keywords[:25]: k = kw.strip().lower().replace(" ", "") if k: keywords.append(kw) tlds = [t.lower().lstrip(".") for t in payload.tlds[:20] if t.strip()] if not tlds: tlds = ["com"] # Build candidate domains candidates: list[tuple[str, str, str]] = [] domain_list: list[str] = [] for kw in keywords: k = kw.strip().lower().replace(" ", "") if not k: continue for t in tlds: d = f"{k}.{t}" candidates.append((kw, t, d)) domain_list.append(d) checked = await check_domains(domain_list, concurrency=40) by_domain = {c.domain: c for c in checked} rows: list[KeywordAvailabilityRow] = [] for kw, t, d in candidates: c = by_domain.get(d) if not c: rows.append(KeywordAvailabilityRow(keyword=kw, domain=d, tld=t, is_available=None, status="unknown")) else: rows.append(KeywordAvailabilityRow(keyword=kw, domain=d, tld=t, is_available=c.is_available, status=c.status)) return KeywordAvailabilityResponse(items=rows) @router.post("/typos", response_model=TypoCheckResponse) @limiter.limit("20/minute") async def typo_check( request: Request, _user: User = Depends(get_current_user), payload: TypoCheckRequest, ): brand = payload.brand.strip() typos = generate_typos(brand, limit=min(int(payload.limit) * 4, 400)) # Build domain list (dedup) tlds = [t.lower().lstrip(".") for t in payload.tlds if t.strip()] candidates: list[str] = [] seen = set() for typo in typos: for t in tlds: d = f"{typo}.{t}" if d not in seen: candidates.append(d) seen.add(d) if len(candidates) >= payload.limit * 4: break if len(candidates) >= payload.limit * 4: break checked = await check_domains(candidates, concurrency=30) available = [c for c in checked if c.status == "available"] items = [TypoCandidate(domain=c.domain, is_available=c.is_available, status=c.status) for c in available[: payload.limit]] return TypoCheckResponse(brand=brand, items=items) @router.post("/brandables", response_model=BrandableResponse) @limiter.limit("15/minute") async def brandables( request: Request, _user: User = Depends(get_current_user), payload: BrandableRequest, ): pattern = payload.pattern.strip().lower() if pattern not in ("cvcvc", "cvccv", "human"): pattern = "cvcvc" tlds = [t.lower().lstrip(".") for t in payload.tlds if t.strip()] if not tlds: tlds = ["com"] # Generate + check up to max_checks; return only available candidates: list[str] = [] for _ in range(int(payload.max_checks)): if pattern == "cvcvc": sld = generate_cvcvc() elif pattern == "cvccv": sld = generate_cvccv() else: sld = generate_human() for t in tlds: candidates.append(f"{sld}.{t}") checked = await check_domains(candidates, concurrency=40) available = [c for c in checked if c.status == "available"] # De-dup by domain seen = set() out = [] for c in available: if c.domain not in seen: seen.add(c.domain) out.append(BrandableCandidate(domain=c.domain, is_available=c.is_available, status=c.status)) if len(out) >= payload.limit: break return BrandableResponse(pattern=payload.pattern, items=out)