AUCTION VALUATIONS (Transparent): - All auctions now include real-time valuation from ValuationService - Shows: estimated_value, value_ratio, potential_profit, confidence - Displays exact formula: "$50 × Length × TLD × Keyword × Brand" - value_ratio helps identify undervalued domains (> 1.0 = opportunity) - Added valuation_note in API response explaining methodology VALUATION FORMULA EXPLAINED: Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor Examples from API response: - healthtech.app: $50 × 1.0 × 0.45 × 1.3 × 1.32 = $40 - blockchain.tech: $50 × 1.0 × 0.35 × 3.0 × 1.12 = $60 - metaverse.ai: $50 × 1.6 × 1.2 × 3.0 × 1.1 = $315 SEO OPTIMIZATIONS: - Root layout: Full Metadata with OpenGraph, Twitter Cards, JSON-LD - JSON-LD Schema: Organization, WebSite, WebApplication with SearchAction - robots.txt: Allows all crawlers including GPTBot, Claude, ChatGPT - sitemap.ts: Dynamic sitemap with all pages + popular TLD pages - Auctions layout: Page-specific meta + ItemList schema - TLD Pricing layout: Product comparison schema LLM OPTIMIZATION: - robots.txt explicitly allows AI crawlers (GPTBot, Anthropic-AI, Claude-Web) - Semantic HTML structure with proper headings - JSON-LD structured data for rich snippets - Descriptive meta descriptions optimized for AI summarization FILES ADDED: - frontend/src/lib/seo.ts - SEO configuration & helpers - frontend/src/app/sitemap.ts - Dynamic sitemap generation - frontend/src/app/auctions/layout.tsx - Auctions SEO - frontend/src/app/tld-pricing/layout.tsx - TLD Pricing SEO - frontend/public/robots.txt - Crawler directives
574 lines
19 KiB
Python
574 lines
19 KiB
Python
"""
|
||
Smart Pounce - Domain Auction Aggregator
|
||
|
||
This module aggregates domain auctions from multiple platforms:
|
||
- GoDaddy Auctions
|
||
- Sedo
|
||
- NameJet
|
||
- SnapNames
|
||
- DropCatch
|
||
|
||
IMPORTANT: This is a META-SEARCH feature.
|
||
We don't host our own auctions - we aggregate and display auctions
|
||
from other platforms, earning affiliate commissions on clicks.
|
||
|
||
Legal Note (Switzerland):
|
||
- No escrow/payment handling = no GwG/FINMA requirements
|
||
- Users click through to external platforms
|
||
- We only provide market intelligence
|
||
|
||
VALUATION:
|
||
All estimated values are calculated using our transparent algorithm:
|
||
Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor
|
||
|
||
See /api/v1/portfolio/valuation/{domain} for full calculation details.
|
||
"""
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional, List
|
||
from fastapi import APIRouter, Depends, Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database import get_db
|
||
from app.api.deps import get_current_user, get_current_user_optional
|
||
from app.models.user import User
|
||
from app.services.valuation import valuation_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
|
||
|
||
# ============== Schemas ==============
|
||
|
||
class AuctionValuation(BaseModel):
|
||
"""Valuation details for an auction."""
|
||
estimated_value: float
|
||
value_ratio: float # estimated_value / current_bid
|
||
potential_profit: float # estimated_value - current_bid
|
||
confidence: str
|
||
valuation_formula: str
|
||
|
||
|
||
class AuctionListing(BaseModel):
|
||
"""A domain auction listing from any platform."""
|
||
domain: str
|
||
platform: str
|
||
platform_url: str
|
||
current_bid: float
|
||
currency: str
|
||
num_bids: int
|
||
end_time: datetime
|
||
time_remaining: str
|
||
buy_now_price: Optional[float] = None
|
||
reserve_met: Optional[bool] = None
|
||
traffic: Optional[int] = None
|
||
age_years: Optional[int] = None
|
||
tld: str
|
||
affiliate_url: str
|
||
# Valuation
|
||
valuation: Optional[AuctionValuation] = None
|
||
|
||
|
||
class AuctionSearchResponse(BaseModel):
|
||
"""Response for auction search."""
|
||
auctions: List[AuctionListing]
|
||
total: int
|
||
platforms_searched: List[str]
|
||
last_updated: datetime
|
||
valuation_note: str = (
|
||
"Values are estimated using our algorithm: "
|
||
"$50 × Length × TLD × Keyword × Brand factors. "
|
||
"See /portfolio/valuation/{domain} for detailed breakdown."
|
||
)
|
||
|
||
|
||
class PlatformStats(BaseModel):
|
||
"""Statistics for an auction platform."""
|
||
platform: str
|
||
active_auctions: int
|
||
avg_bid: float
|
||
ending_soon: int
|
||
|
||
|
||
# ============== Mock Data (for demo - replace with real scrapers) ==============
|
||
|
||
MOCK_AUCTIONS = [
|
||
{
|
||
"domain": "cryptopay.io",
|
||
"platform": "GoDaddy",
|
||
"current_bid": 2500,
|
||
"num_bids": 23,
|
||
"end_time": datetime.utcnow() + timedelta(hours=2, minutes=34),
|
||
"buy_now_price": 15000,
|
||
"reserve_met": True,
|
||
"traffic": 1200,
|
||
"age_years": 8,
|
||
},
|
||
{
|
||
"domain": "aitools.com",
|
||
"platform": "Sedo",
|
||
"current_bid": 8750,
|
||
"num_bids": 45,
|
||
"end_time": datetime.utcnow() + timedelta(hours=5, minutes=12),
|
||
"buy_now_price": None,
|
||
"reserve_met": True,
|
||
"traffic": 3400,
|
||
"age_years": 12,
|
||
},
|
||
{
|
||
"domain": "nftmarket.co",
|
||
"platform": "NameJet",
|
||
"current_bid": 850,
|
||
"num_bids": 12,
|
||
"end_time": datetime.utcnow() + timedelta(hours=1, minutes=5),
|
||
"buy_now_price": 5000,
|
||
"reserve_met": False,
|
||
"traffic": 500,
|
||
"age_years": 4,
|
||
},
|
||
{
|
||
"domain": "cloudservices.net",
|
||
"platform": "GoDaddy",
|
||
"current_bid": 1200,
|
||
"num_bids": 8,
|
||
"end_time": datetime.utcnow() + timedelta(hours=12, minutes=45),
|
||
"buy_now_price": 7500,
|
||
"reserve_met": True,
|
||
"traffic": 800,
|
||
"age_years": 15,
|
||
},
|
||
{
|
||
"domain": "blockchain.tech",
|
||
"platform": "Sedo",
|
||
"current_bid": 3200,
|
||
"num_bids": 31,
|
||
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=45),
|
||
"buy_now_price": None,
|
||
"reserve_met": True,
|
||
"traffic": 2100,
|
||
"age_years": 6,
|
||
},
|
||
{
|
||
"domain": "startupfund.io",
|
||
"platform": "NameJet",
|
||
"current_bid": 650,
|
||
"num_bids": 5,
|
||
"end_time": datetime.utcnow() + timedelta(hours=8, minutes=20),
|
||
"buy_now_price": 3000,
|
||
"reserve_met": False,
|
||
"traffic": 150,
|
||
"age_years": 3,
|
||
},
|
||
{
|
||
"domain": "metaverse.ai",
|
||
"platform": "GoDaddy",
|
||
"current_bid": 12500,
|
||
"num_bids": 67,
|
||
"end_time": datetime.utcnow() + timedelta(hours=3, minutes=15),
|
||
"buy_now_price": 50000,
|
||
"reserve_met": True,
|
||
"traffic": 5000,
|
||
"age_years": 2,
|
||
},
|
||
{
|
||
"domain": "defiswap.com",
|
||
"platform": "Sedo",
|
||
"current_bid": 4500,
|
||
"num_bids": 28,
|
||
"end_time": datetime.utcnow() + timedelta(hours=6, minutes=30),
|
||
"buy_now_price": 20000,
|
||
"reserve_met": True,
|
||
"traffic": 1800,
|
||
"age_years": 5,
|
||
},
|
||
{
|
||
"domain": "healthtech.app",
|
||
"platform": "DropCatch",
|
||
"current_bid": 420,
|
||
"num_bids": 7,
|
||
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=15),
|
||
"buy_now_price": None,
|
||
"reserve_met": None,
|
||
"traffic": 300,
|
||
"age_years": 2,
|
||
},
|
||
{
|
||
"domain": "gameverse.io",
|
||
"platform": "SnapNames",
|
||
"current_bid": 1100,
|
||
"num_bids": 15,
|
||
"end_time": datetime.utcnow() + timedelta(hours=4, minutes=0),
|
||
"buy_now_price": 5500,
|
||
"reserve_met": True,
|
||
"traffic": 900,
|
||
"age_years": 4,
|
||
},
|
||
]
|
||
|
||
# Platform affiliate URLs
|
||
PLATFORM_URLS = {
|
||
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?miession=&domain=",
|
||
"Sedo": "https://sedo.com/search/?keyword=",
|
||
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
|
||
"SnapNames": "https://www.snapnames.com/results.aspx?q=",
|
||
"DropCatch": "https://www.dropcatch.com/domain/",
|
||
}
|
||
|
||
|
||
def _format_time_remaining(end_time: datetime) -> str:
|
||
"""Format time remaining in human-readable format."""
|
||
delta = end_time - datetime.utcnow()
|
||
|
||
if delta.total_seconds() <= 0:
|
||
return "Ended"
|
||
|
||
hours = int(delta.total_seconds() // 3600)
|
||
minutes = int((delta.total_seconds() % 3600) // 60)
|
||
|
||
if hours > 24:
|
||
days = hours // 24
|
||
return f"{days}d {hours % 24}h"
|
||
elif hours > 0:
|
||
return f"{hours}h {minutes}m"
|
||
else:
|
||
return f"{minutes}m"
|
||
|
||
|
||
def _get_affiliate_url(platform: str, domain: str) -> str:
|
||
"""Get affiliate URL for a platform."""
|
||
base_url = PLATFORM_URLS.get(platform, "")
|
||
return f"{base_url}{domain}"
|
||
|
||
|
||
async def _convert_to_listing(auction: dict, db: AsyncSession, include_valuation: bool = True) -> AuctionListing:
|
||
"""Convert raw auction data to AuctionListing with valuation."""
|
||
domain = auction["domain"]
|
||
tld = domain.rsplit(".", 1)[-1] if "." in domain else ""
|
||
current_bid = auction["current_bid"]
|
||
|
||
valuation_data = None
|
||
|
||
if include_valuation:
|
||
try:
|
||
# Get real valuation from our service
|
||
result = await valuation_service.estimate_value(domain, db, save_result=False)
|
||
|
||
if "error" not in result:
|
||
estimated_value = result["estimated_value"]
|
||
value_ratio = round(estimated_value / current_bid, 2) if current_bid > 0 else 99
|
||
|
||
valuation_data = AuctionValuation(
|
||
estimated_value=estimated_value,
|
||
value_ratio=value_ratio,
|
||
potential_profit=round(estimated_value - current_bid, 2),
|
||
confidence=result.get("confidence", "medium"),
|
||
valuation_formula=result.get("calculation", {}).get("formula", "N/A"),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Valuation error for {domain}: {e}")
|
||
|
||
return AuctionListing(
|
||
domain=domain,
|
||
platform=auction["platform"],
|
||
platform_url=PLATFORM_URLS.get(auction["platform"], ""),
|
||
current_bid=current_bid,
|
||
currency="USD",
|
||
num_bids=auction["num_bids"],
|
||
end_time=auction["end_time"],
|
||
time_remaining=_format_time_remaining(auction["end_time"]),
|
||
buy_now_price=auction.get("buy_now_price"),
|
||
reserve_met=auction.get("reserve_met"),
|
||
traffic=auction.get("traffic"),
|
||
age_years=auction.get("age_years"),
|
||
tld=tld,
|
||
affiliate_url=_get_affiliate_url(auction["platform"], domain),
|
||
valuation=valuation_data,
|
||
)
|
||
|
||
|
||
# ============== Endpoints ==============
|
||
|
||
@router.get("", response_model=AuctionSearchResponse)
|
||
async def search_auctions(
|
||
keyword: Optional[str] = Query(None, description="Search keyword in domain names"),
|
||
tld: Optional[str] = Query(None, description="Filter by TLD (e.g., 'com', 'io')"),
|
||
platform: Optional[str] = Query(None, description="Filter by platform"),
|
||
min_bid: Optional[float] = Query(None, ge=0, description="Minimum current bid"),
|
||
max_bid: Optional[float] = Query(None, ge=0, description="Maximum current bid"),
|
||
ending_soon: bool = Query(False, description="Only show auctions ending in < 1 hour"),
|
||
sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids", "value_ratio"]),
|
||
limit: int = Query(20, le=100),
|
||
offset: int = Query(0, ge=0),
|
||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Search domain auctions across multiple platforms.
|
||
|
||
This is a META-SEARCH feature:
|
||
- We aggregate listings from GoDaddy, Sedo, NameJet, etc.
|
||
- Clicking through uses affiliate links
|
||
- We do NOT handle payments or transfers
|
||
|
||
All auctions include estimated values calculated using our algorithm:
|
||
Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor
|
||
|
||
Smart Pounce Strategy:
|
||
- Look for value_ratio > 1.0 (estimated value exceeds current bid)
|
||
- Focus on auctions ending soon with low bid counts
|
||
- Track keywords you're interested in
|
||
"""
|
||
auctions = MOCK_AUCTIONS.copy()
|
||
|
||
# Apply filters
|
||
if keyword:
|
||
keyword_lower = keyword.lower()
|
||
auctions = [a for a in auctions if keyword_lower in a["domain"].lower()]
|
||
|
||
if tld:
|
||
tld_clean = tld.lower().lstrip(".")
|
||
auctions = [a for a in auctions if a["domain"].endswith(f".{tld_clean}")]
|
||
|
||
if platform:
|
||
auctions = [a for a in auctions if a["platform"].lower() == platform.lower()]
|
||
|
||
if min_bid is not None:
|
||
auctions = [a for a in auctions if a["current_bid"] >= min_bid]
|
||
|
||
if max_bid is not None:
|
||
auctions = [a for a in auctions if a["current_bid"] <= max_bid]
|
||
|
||
if ending_soon:
|
||
cutoff = datetime.utcnow() + timedelta(hours=1)
|
||
auctions = [a for a in auctions if a["end_time"] <= cutoff]
|
||
|
||
# Sort (before valuation for efficiency, except value_ratio)
|
||
if sort_by == "ending":
|
||
auctions.sort(key=lambda x: x["end_time"])
|
||
elif sort_by == "bid_asc":
|
||
auctions.sort(key=lambda x: x["current_bid"])
|
||
elif sort_by == "bid_desc":
|
||
auctions.sort(key=lambda x: x["current_bid"], reverse=True)
|
||
elif sort_by == "bids":
|
||
auctions.sort(key=lambda x: x["num_bids"], reverse=True)
|
||
|
||
total = len(auctions)
|
||
auctions = auctions[offset:offset + limit]
|
||
|
||
# Convert to response format with valuations
|
||
listings = []
|
||
for a in auctions:
|
||
listing = await _convert_to_listing(a, db, include_valuation=True)
|
||
listings.append(listing)
|
||
|
||
# Sort by value_ratio if requested (after valuation)
|
||
if sort_by == "value_ratio":
|
||
listings.sort(
|
||
key=lambda x: x.valuation.value_ratio if x.valuation else 0,
|
||
reverse=True
|
||
)
|
||
|
||
return AuctionSearchResponse(
|
||
auctions=listings,
|
||
total=total,
|
||
platforms_searched=list(PLATFORM_URLS.keys()),
|
||
last_updated=datetime.utcnow(),
|
||
)
|
||
|
||
|
||
@router.get("/ending-soon", response_model=List[AuctionListing])
|
||
async def get_ending_soon(
|
||
hours: int = Query(1, ge=1, le=24, description="Hours until end"),
|
||
limit: int = Query(10, le=50),
|
||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Get auctions ending soon - best opportunities for sniping.
|
||
|
||
Smart Pounce Tip:
|
||
- Auctions ending in < 1 hour often have final bidding frenzy
|
||
- Low-bid auctions ending soon can be bargains
|
||
- Look for value_ratio > 1.0 (undervalued domains)
|
||
"""
|
||
cutoff = datetime.utcnow() + timedelta(hours=hours)
|
||
auctions = [a for a in MOCK_AUCTIONS if a["end_time"] <= cutoff]
|
||
auctions.sort(key=lambda x: x["end_time"])
|
||
auctions = auctions[:limit]
|
||
|
||
listings = []
|
||
for a in auctions:
|
||
listing = await _convert_to_listing(a, db, include_valuation=True)
|
||
listings.append(listing)
|
||
|
||
return listings
|
||
|
||
|
||
@router.get("/hot", response_model=List[AuctionListing])
|
||
async def get_hot_auctions(
|
||
limit: int = Query(10, le=50),
|
||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Get hottest auctions by bidding activity.
|
||
|
||
These auctions have the most competition - high demand indicators.
|
||
High demand often correlates with quality domains.
|
||
"""
|
||
auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True)
|
||
auctions = auctions[:limit]
|
||
|
||
listings = []
|
||
for a in auctions:
|
||
listing = await _convert_to_listing(a, db, include_valuation=True)
|
||
listings.append(listing)
|
||
|
||
return listings
|
||
|
||
|
||
@router.get("/stats", response_model=List[PlatformStats])
|
||
async def get_platform_stats(
|
||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||
):
|
||
"""
|
||
Get statistics for each auction platform.
|
||
|
||
Useful for understanding where the best deals are.
|
||
"""
|
||
stats = {}
|
||
|
||
for auction in MOCK_AUCTIONS:
|
||
platform = auction["platform"]
|
||
if platform not in stats:
|
||
stats[platform] = {
|
||
"platform": platform,
|
||
"auctions": [],
|
||
"ending_soon_count": 0,
|
||
}
|
||
stats[platform]["auctions"].append(auction)
|
||
|
||
if auction["end_time"] <= datetime.utcnow() + timedelta(hours=1):
|
||
stats[platform]["ending_soon_count"] += 1
|
||
|
||
result = []
|
||
for platform, data in stats.items():
|
||
auctions = data["auctions"]
|
||
result.append(PlatformStats(
|
||
platform=platform,
|
||
active_auctions=len(auctions),
|
||
avg_bid=round(sum(a["current_bid"] for a in auctions) / len(auctions), 2),
|
||
ending_soon=data["ending_soon_count"],
|
||
))
|
||
|
||
return sorted(result, key=lambda x: x.active_auctions, reverse=True)
|
||
|
||
|
||
@router.get("/opportunities")
|
||
async def get_smart_opportunities(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Smart Pounce Algorithm - Find the best auction opportunities.
|
||
|
||
Our algorithm scores each auction based on:
|
||
1. Value Ratio: estimated_value / current_bid (higher = better deal)
|
||
2. Time Factor: Auctions ending soon get 2× boost
|
||
3. Bid Factor: Low bid count (< 10) gets 1.5× boost
|
||
|
||
Opportunity Score = value_ratio × time_factor × bid_factor
|
||
|
||
Recommendations:
|
||
- "Strong buy": Score > 5 (significantly undervalued)
|
||
- "Consider": Score 2-5 (potential opportunity)
|
||
- "Monitor": Score < 2 (fairly priced)
|
||
|
||
Requires authentication to personalize results.
|
||
"""
|
||
opportunities = []
|
||
|
||
for auction in MOCK_AUCTIONS:
|
||
valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False)
|
||
|
||
if "error" in valuation:
|
||
continue
|
||
|
||
estimated_value = valuation["estimated_value"]
|
||
current_bid = auction["current_bid"]
|
||
|
||
value_ratio = estimated_value / current_bid if current_bid > 0 else 10
|
||
|
||
hours_left = (auction["end_time"] - datetime.utcnow()).total_seconds() / 3600
|
||
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
|
||
|
||
bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0
|
||
|
||
opportunity_score = value_ratio * time_factor * bid_factor
|
||
|
||
listing = await _convert_to_listing(auction, db, include_valuation=True)
|
||
opportunities.append({
|
||
"auction": listing.model_dump(),
|
||
"analysis": {
|
||
"estimated_value": estimated_value,
|
||
"current_bid": current_bid,
|
||
"value_ratio": round(value_ratio, 2),
|
||
"potential_profit": round(estimated_value - current_bid, 2),
|
||
"opportunity_score": round(opportunity_score, 2),
|
||
"time_factor": time_factor,
|
||
"bid_factor": bid_factor,
|
||
"recommendation": (
|
||
"Strong buy" if opportunity_score > 5 else
|
||
"Consider" if opportunity_score > 2 else
|
||
"Monitor"
|
||
),
|
||
"reasoning": _get_opportunity_reasoning(
|
||
value_ratio, hours_left, auction["num_bids"], opportunity_score
|
||
),
|
||
}
|
||
})
|
||
|
||
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
||
|
||
return {
|
||
"opportunities": opportunities[:10],
|
||
"valuation_method": (
|
||
"Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. "
|
||
"See /portfolio/valuation/{domain} for detailed breakdown of any domain."
|
||
),
|
||
"strategy_tips": [
|
||
"🎯 Focus on value_ratio > 1.0 (estimated value exceeds current bid)",
|
||
"⏰ Auctions ending in < 1 hour often have best snipe opportunities",
|
||
"📉 Low bid count (< 10) might indicate overlooked gems",
|
||
"💡 Premium TLDs (.com, .ai, .io) have highest aftermarket demand",
|
||
],
|
||
"generated_at": datetime.utcnow().isoformat(),
|
||
}
|
||
|
||
|
||
def _get_opportunity_reasoning(value_ratio: float, hours_left: float, num_bids: int, score: float) -> str:
|
||
"""Generate human-readable reasoning for the opportunity."""
|
||
reasons = []
|
||
|
||
if value_ratio > 2:
|
||
reasons.append(f"Significantly undervalued ({value_ratio:.1f}× estimated value)")
|
||
elif value_ratio > 1:
|
||
reasons.append(f"Undervalued ({value_ratio:.1f}× estimated value)")
|
||
else:
|
||
reasons.append(f"Current bid exceeds our estimate ({value_ratio:.2f}×)")
|
||
|
||
if hours_left < 1:
|
||
reasons.append("⚡ Ending very soon - final chance to bid")
|
||
elif hours_left < 4:
|
||
reasons.append("⏰ Ending soon - limited time remaining")
|
||
|
||
if num_bids < 5:
|
||
reasons.append("📉 Very low competition - potential overlooked opportunity")
|
||
elif num_bids < 10:
|
||
reasons.append("📊 Moderate competition")
|
||
else:
|
||
reasons.append(f"🔥 High demand ({num_bids} bids)")
|
||
|
||
return " | ".join(reasons)
|