From ded6c341002a674eddec0ab232c0bd00ffff7de3 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Wed, 10 Dec 2025 11:58:05 +0100 Subject: [PATCH] feat: Add SEO Juice Detector (Tycoon feature) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From analysis_3.md - Strategie 3: SEO-Daten & Backlinks: 'SEO-Agenturen suchen Domains wegen der Power (Backlinks). Solche Domains sind für SEOs 100-500€ wert, auch wenn der Name hässlich ist.' BACKEND: - Model: DomainSEOData for caching SEO metrics - Service: seo_analyzer.py with Moz API integration - Falls back to estimation if no API keys - Detects notable links (Wikipedia, .gov, .edu, news) - Calculates SEO value estimate - API: /seo endpoints (Tycoon-only access) FRONTEND: - /command/seo page with full SEO analysis - Upgrade prompt for non-Tycoon users - Notable links display (Wikipedia, .gov, .edu, news) - Top backlinks with authority scores - Recent searches saved locally SIDEBAR: - Added 'SEO Juice' nav item with 'Tycoon' badge DOCS: - Updated DATABASE_MIGRATIONS.md with domain_seo_data table - Added SEO API endpoints documentation - Added Moz API environment variables info --- DATABASE_MIGRATIONS.md | 47 ++- backend/app/api/__init__.py | 4 + backend/app/api/seo.py | 242 +++++++++++++ backend/app/models/__init__.py | 3 + backend/app/models/seo_data.py | 116 ++++++ backend/app/services/seo_analyzer.py | 381 ++++++++++++++++++++ frontend/src/app/command/seo/page.tsx | 496 ++++++++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 7 + 8 files changed, 1295 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/seo.py create mode 100644 backend/app/models/seo_data.py create mode 100644 backend/app/services/seo_analyzer.py create mode 100644 frontend/src/app/command/seo/page.tsx diff --git a/DATABASE_MIGRATIONS.md b/DATABASE_MIGRATIONS.md index 1971997..b2479df 100644 --- a/DATABASE_MIGRATIONS.md +++ b/DATABASE_MIGRATIONS.md @@ -166,6 +166,34 @@ CREATE TABLE sniper_alert_matches ( CREATE INDEX idx_matches_alert_id ON sniper_alert_matches(alert_id); ``` +#### 3. SEO Data (Tycoon Feature) + +```sql +-- Cached SEO metrics for domains +CREATE TABLE domain_seo_data ( + id SERIAL PRIMARY KEY, + domain VARCHAR(255) NOT NULL UNIQUE, + domain_authority INTEGER, + page_authority INTEGER, + spam_score INTEGER, + total_backlinks INTEGER, + referring_domains INTEGER, + top_backlinks JSONB, + notable_backlinks TEXT, + has_wikipedia_link BOOLEAN DEFAULT FALSE, + has_gov_link BOOLEAN DEFAULT FALSE, + has_edu_link BOOLEAN DEFAULT FALSE, + has_news_link BOOLEAN DEFAULT FALSE, + seo_value_estimate FLOAT, + data_source VARCHAR(50) DEFAULT 'moz', + last_updated TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP, + fetch_count INTEGER DEFAULT 0 +); + +CREATE INDEX idx_seo_domain ON domain_seo_data(domain); +``` + --- ## Migration Commands @@ -201,7 +229,8 @@ AND table_name IN ( 'listing_inquiries', 'listing_views', 'sniper_alerts', - 'sniper_alert_matches' + 'sniper_alert_matches', + 'domain_seo_data' ); ``` @@ -244,3 +273,19 @@ These tables implement features from: | GET | `/api/v1/sniper-alerts/{id}/matches` | Get matched auctions | | POST | `/api/v1/sniper-alerts/{id}/test` | Test alert criteria | +### SEO Data (Tycoon Only) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/seo/{domain}` | Full SEO analysis (Tycoon) | +| POST | `/api/v1/seo/batch` | Batch analyze domains (Tycoon) | +| GET | `/api/v1/seo/{domain}/quick` | Quick summary (Trader+) | + +**Environment Variables for Moz API:** +``` +MOZ_ACCESS_ID=your_access_id +MOZ_SECRET_KEY=your_secret_key +``` + +Without these, the system uses estimation mode based on domain characteristics. + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 2e75ba5..2b8a739 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -16,6 +16,7 @@ from app.api.price_alerts import router as price_alerts_router from app.api.blog import router as blog_router from app.api.listings import router as listings_router from app.api.sniper_alerts import router as sniper_alerts_router +from app.api.seo import router as seo_router api_router = APIRouter() @@ -36,6 +37,9 @@ api_router.include_router(listings_router, prefix="/listings", tags=["Marketplac # Sniper Alerts - from analysis_3.md api_router.include_router(sniper_alerts_router, prefix="/sniper-alerts", tags=["Sniper Alerts"]) +# SEO Data / Backlinks - from analysis_3.md (Tycoon-only) +api_router.include_router(seo_router, prefix="/seo", tags=["SEO Data - Tycoon"]) + # Support & Communication api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"]) diff --git a/backend/app/api/seo.py b/backend/app/api/seo.py new file mode 100644 index 0000000..f15e815 --- /dev/null +++ b/backend/app/api/seo.py @@ -0,0 +1,242 @@ +""" +SEO Data API - "SEO Juice Detector" + +This implements Strategie 3 from analysis_3.md: +"Das Feature: 'SEO Juice Detector' +Wenn eine Domain droppt, prüfst du nicht nur den Namen, +sondern ob Backlinks existieren. +Monetarisierung: Das ist ein reines Tycoon-Feature ($29/Monat)." + +Endpoints: +- GET /seo/{domain} - Get SEO data for a domain (TYCOON ONLY) +- POST /seo/batch - Analyze multiple domains (TYCOON ONLY) +""" +import logging +from typing import List +from fastapi import APIRouter, Depends, HTTPException, 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 +from app.models.user import User +from app.services.seo_analyzer import seo_analyzer + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Schemas ============== + +class SEOMetrics(BaseModel): + domain_authority: int | None + page_authority: int | None + spam_score: int | None + total_backlinks: int | None + referring_domains: int | None + + +class NotableLinks(BaseModel): + has_wikipedia: bool + has_gov: bool + has_edu: bool + has_news: bool + notable_domains: List[str] + + +class BacklinkInfo(BaseModel): + domain: str + authority: int + page: str = "" + + +class SEOResponse(BaseModel): + domain: str + seo_score: int + value_category: str + metrics: SEOMetrics + notable_links: NotableLinks + top_backlinks: List[BacklinkInfo] + estimated_value: float | None + data_source: str + last_updated: str | None + is_estimated: bool + + +class BatchSEORequest(BaseModel): + domains: List[str] + + +class BatchSEOResponse(BaseModel): + results: List[SEOResponse] + total_requested: int + total_processed: int + + +# ============== Helper ============== + +def _check_tycoon_access(user: User) -> None: + """Verify user has Tycoon tier access.""" + if not user.subscription: + raise HTTPException( + status_code=403, + detail="SEO data is a Tycoon feature. Please upgrade your subscription." + ) + + tier = user.subscription.tier.lower() if user.subscription.tier else "" + if tier != "tycoon": + raise HTTPException( + status_code=403, + detail="SEO data is a Tycoon-only feature. Please upgrade to access backlink analysis." + ) + + +# ============== Endpoints ============== + +@router.get("/{domain}", response_model=SEOResponse) +async def get_seo_data( + domain: str, + force_refresh: bool = Query(False, description="Force refresh from API"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Get SEO data for a domain. + + TYCOON FEATURE ONLY. + + Returns: + - Domain Authority (0-100) + - Page Authority (0-100) + - Spam Score (0-100) + - Total Backlinks + - Referring Domains + - Notable links (Wikipedia, .gov, .edu, news sites) + - Top backlinks with authority scores + - Estimated SEO value + + From analysis_3.md: + "Domain `alte-bäckerei-münchen.de` ist frei. + Hat Links von `sueddeutsche.de` und `wikipedia.org`." + """ + # Check Tycoon access + _check_tycoon_access(current_user) + + # Clean domain input + domain = domain.lower().strip() + if domain.startswith('http://'): + domain = domain[7:] + if domain.startswith('https://'): + domain = domain[8:] + if domain.startswith('www.'): + domain = domain[4:] + domain = domain.rstrip('/') + + # Get SEO data + result = await seo_analyzer.analyze_domain(domain, db, force_refresh) + + return SEOResponse(**result) + + +@router.post("/batch", response_model=BatchSEOResponse) +async def batch_seo_analysis( + request: BatchSEORequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Analyze multiple domains for SEO data. + + TYCOON FEATURE ONLY. + + Limited to 10 domains per request to prevent abuse. + """ + # Check Tycoon access + _check_tycoon_access(current_user) + + # Limit batch size + domains = request.domains[:10] + + results = [] + for domain in domains: + try: + # Clean domain + domain = domain.lower().strip() + if domain.startswith('http://'): + domain = domain[7:] + if domain.startswith('https://'): + domain = domain[8:] + if domain.startswith('www.'): + domain = domain[4:] + domain = domain.rstrip('/') + + result = await seo_analyzer.analyze_domain(domain, db) + results.append(SEOResponse(**result)) + except Exception as e: + logger.error(f"Error analyzing {domain}: {e}") + # Skip failed domains + continue + + return BatchSEOResponse( + results=results, + total_requested=len(request.domains), + total_processed=len(results), + ) + + +@router.get("/{domain}/quick") +async def get_seo_quick_summary( + domain: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Get a quick SEO summary for a domain. + + This is a lighter version that shows basic metrics without full backlink analysis. + Available to Trader+ users. + """ + # Check at least Trader access + if not current_user.subscription: + raise HTTPException( + status_code=403, + detail="SEO data requires a paid subscription." + ) + + tier = current_user.subscription.tier.lower() if current_user.subscription.tier else "" + if tier == "scout": + raise HTTPException( + status_code=403, + detail="SEO data requires Trader or higher subscription." + ) + + # Clean domain + domain = domain.lower().strip().rstrip('/') + if domain.startswith('http://'): + domain = domain[7:] + if domain.startswith('https://'): + domain = domain[8:] + if domain.startswith('www.'): + domain = domain[4:] + + result = await seo_analyzer.analyze_domain(domain, db) + + # Return limited data for non-Tycoon + if tier != "tycoon": + return { + 'domain': result['domain'], + 'seo_score': result['seo_score'], + 'value_category': result['value_category'], + 'domain_authority': result['metrics']['domain_authority'], + 'has_notable_links': ( + result['notable_links']['has_wikipedia'] or + result['notable_links']['has_gov'] or + result['notable_links']['has_news'] + ), + 'is_estimated': result['is_estimated'], + 'upgrade_for_details': True, + 'message': "Upgrade to Tycoon for full backlink analysis" + } + + return result + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4007714..e628373 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.admin_log import AdminActivityLog from app.models.blog import BlogPost from app.models.listing import DomainListing, ListingInquiry, ListingView from app.models.sniper_alert import SniperAlert, SniperAlertMatch +from app.models.seo_data import DomainSEOData __all__ = [ "User", @@ -34,4 +35,6 @@ __all__ = [ # New: Sniper Alerts "SniperAlert", "SniperAlertMatch", + # New: SEO Data (Tycoon feature) + "DomainSEOData", ] diff --git a/backend/app/models/seo_data.py b/backend/app/models/seo_data.py new file mode 100644 index 0000000..7b4cef8 --- /dev/null +++ b/backend/app/models/seo_data.py @@ -0,0 +1,116 @@ +""" +SEO Data models for the "SEO Juice Detector" feature. + +This implements "Strategie 3: SEO-Daten & Backlinks" from analysis_3.md: +"SEO-Agenturen suchen Domains nicht wegen dem Namen, sondern wegen der Power (Backlinks). +Wenn eine Domain droppt, prüfst du nicht nur den Namen, sondern ob Backlinks existieren." + +This is a TYCOON-ONLY feature ($29/month). + +DATABASE TABLE TO CREATE: +- domain_seo_data - Cached SEO metrics for domains + +Run migrations: alembic upgrade head +""" +from datetime import datetime +from typing import Optional, List +from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class DomainSEOData(Base): + """ + Cached SEO data for domains. + + Stores backlink data, domain authority, and other SEO metrics + from Moz API or alternative sources. + + From analysis_3.md: + "Domain `alte-bäckerei-münchen.de` ist frei. + Hat Links von `sueddeutsche.de` und `wikipedia.org`." + """ + + __tablename__ = "domain_seo_data" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + + # Moz metrics + domain_authority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100 + page_authority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100 + spam_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100 + + # Backlink data + total_backlinks: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + referring_domains: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # Top backlinks (JSON array of {domain, authority, type}) + top_backlinks: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # Notable backlinks (high-authority sites) + notable_backlinks: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Comma-separated + has_wikipedia_link: Mapped[bool] = mapped_column(Boolean, default=False) + has_gov_link: Mapped[bool] = mapped_column(Boolean, default=False) + has_edu_link: Mapped[bool] = mapped_column(Boolean, default=False) + has_news_link: Mapped[bool] = mapped_column(Boolean, default=False) + + # Estimated value based on SEO + seo_value_estimate: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # Data source + data_source: Mapped[str] = mapped_column(String(50), default="moz") # moz, ahrefs, majestic, estimated + + # Cache management + last_updated: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Request tracking + fetch_count: Mapped[int] = mapped_column(Integer, default=0) + + def __repr__(self) -> str: + return f"" + + @property + def is_expired(self) -> bool: + if not self.expires_at: + return True + return datetime.utcnow() > self.expires_at + + @property + def seo_score(self) -> int: + """Calculate overall SEO score (0-100).""" + if not self.domain_authority: + return 0 + + score = self.domain_authority + + # Boost for notable links + if self.has_wikipedia_link: + score = min(100, score + 10) + if self.has_gov_link: + score = min(100, score + 5) + if self.has_edu_link: + score = min(100, score + 5) + if self.has_news_link: + score = min(100, score + 3) + + # Penalty for spam + if self.spam_score and self.spam_score > 30: + score = max(0, score - (self.spam_score // 5)) + + return score + + @property + def value_category(self) -> str: + """Categorize SEO value for display.""" + score = self.seo_score + if score >= 60: + return "High Value" + elif score >= 40: + return "Medium Value" + elif score >= 20: + return "Low Value" + return "Minimal" + diff --git a/backend/app/services/seo_analyzer.py b/backend/app/services/seo_analyzer.py new file mode 100644 index 0000000..fb90e66 --- /dev/null +++ b/backend/app/services/seo_analyzer.py @@ -0,0 +1,381 @@ +""" +SEO Analyzer Service - "SEO Juice Detector" + +This implements Strategie 3 from analysis_3.md: +"SEO-Agenturen suchen Domains wegen der Power (Backlinks). +Solche Domains sind für SEOs 100€ - 500€ wert, auch wenn der Name hässlich ist." + +Data Sources (in priority order): +1. Moz API (if MOZ_ACCESS_ID and MOZ_SECRET_KEY are set) +2. CommonCrawl Index (free, but limited) +3. Estimation based on domain characteristics + +This is a TYCOON-ONLY feature. +""" +import os +import logging +import base64 +import hashlib +import hmac +import time +import httpx +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.seo_data import DomainSEOData + +logger = logging.getLogger(__name__) + + +class SEOAnalyzerService: + """ + Analyzes domains for SEO value (backlinks, authority, etc.) + + From analysis_3.md: + "Domain `alte-bäckerei-münchen.de` ist frei. + Hat Links von `sueddeutsche.de` und `wikipedia.org`." + """ + + # Moz API configuration + MOZ_API_URL = "https://lsapi.seomoz.com/v2/url_metrics" + MOZ_LINKS_URL = "https://lsapi.seomoz.com/v2/links" + + # Cache duration (7 days for SEO data) + CACHE_DURATION_DAYS = 7 + + # Known high-authority domains for notable link detection + NOTABLE_DOMAINS = { + 'wikipedia': ['wikipedia.org', 'wikimedia.org'], + 'gov': ['.gov', '.gov.uk', '.admin.ch', '.bund.de'], + 'edu': ['.edu', '.ac.uk', '.ethz.ch', '.uzh.ch'], + 'news': [ + 'nytimes.com', 'theguardian.com', 'bbc.com', 'cnn.com', + 'forbes.com', 'bloomberg.com', 'reuters.com', 'techcrunch.com', + 'spiegel.de', 'faz.net', 'nzz.ch', 'tagesanzeiger.ch' + ] + } + + def __init__(self): + self.moz_access_id = os.getenv('MOZ_ACCESS_ID') + self.moz_secret_key = os.getenv('MOZ_SECRET_KEY') + self.has_moz = bool(self.moz_access_id and self.moz_secret_key) + + if self.has_moz: + logger.info("SEO Analyzer: Moz API configured") + else: + logger.warning("SEO Analyzer: No Moz API keys - using estimation mode") + + async def analyze_domain( + self, + domain: str, + db: AsyncSession, + force_refresh: bool = False + ) -> Dict[str, Any]: + """ + Analyze a domain for SEO value. + + Returns: + Dict with SEO metrics, backlinks, and value estimate + """ + domain = domain.lower().strip() + + # Check cache first + if not force_refresh: + cached = await self._get_cached(domain, db) + if cached and not cached.is_expired: + return self._format_response(cached) + + # Fetch fresh data + if self.has_moz: + seo_data = await self._fetch_moz_data(domain) + else: + seo_data = await self._estimate_seo_data(domain) + + # Save to cache + cached = await self._save_to_cache(domain, seo_data, db) + + return self._format_response(cached) + + async def _get_cached(self, domain: str, db: AsyncSession) -> Optional[DomainSEOData]: + """Get cached SEO data for a domain.""" + result = await db.execute( + select(DomainSEOData).where(DomainSEOData.domain == domain) + ) + return result.scalar_one_or_none() + + async def _save_to_cache( + self, + domain: str, + data: Dict[str, Any], + db: AsyncSession + ) -> DomainSEOData: + """Save SEO data to cache.""" + # Check if exists + result = await db.execute( + select(DomainSEOData).where(DomainSEOData.domain == domain) + ) + cached = result.scalar_one_or_none() + + if cached: + # Update existing + for key, value in data.items(): + if hasattr(cached, key): + setattr(cached, key, value) + cached.last_updated = datetime.utcnow() + cached.expires_at = datetime.utcnow() + timedelta(days=self.CACHE_DURATION_DAYS) + cached.fetch_count += 1 + else: + # Create new + cached = DomainSEOData( + domain=domain, + expires_at=datetime.utcnow() + timedelta(days=self.CACHE_DURATION_DAYS), + **data + ) + db.add(cached) + + await db.commit() + await db.refresh(cached) + return cached + + async def _fetch_moz_data(self, domain: str) -> Dict[str, Any]: + """Fetch SEO data from Moz API.""" + try: + # Generate authentication + expires = int(time.time()) + 300 + string_to_sign = f"{self.moz_access_id}\n{expires}" + signature = base64.b64encode( + hmac.new( + self.moz_secret_key.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha1 + ).digest() + ).decode('utf-8') + + auth_params = { + 'AccessID': self.moz_access_id, + 'Expires': expires, + 'Signature': signature + } + + async with httpx.AsyncClient(timeout=30) as client: + # Get URL metrics + response = await client.post( + self.MOZ_API_URL, + params=auth_params, + json={ + 'targets': [f'http://{domain}/'], + } + ) + + if response.status_code == 200: + metrics = response.json() + if metrics and 'results' in metrics and metrics['results']: + result = metrics['results'][0] + + # Extract notable backlinks + top_backlinks = await self._fetch_top_backlinks( + domain, auth_params, client + ) + + return { + 'domain_authority': result.get('domain_authority', 0), + 'page_authority': result.get('page_authority', 0), + 'spam_score': result.get('spam_score', 0), + 'total_backlinks': result.get('external_links_to_root_domain', 0), + 'referring_domains': result.get('root_domains_to_root_domain', 0), + 'top_backlinks': top_backlinks, + 'notable_backlinks': self._extract_notable(top_backlinks), + 'has_wikipedia_link': self._has_notable_link(top_backlinks, 'wikipedia'), + 'has_gov_link': self._has_notable_link(top_backlinks, 'gov'), + 'has_edu_link': self._has_notable_link(top_backlinks, 'edu'), + 'has_news_link': self._has_notable_link(top_backlinks, 'news'), + 'seo_value_estimate': self._calculate_seo_value(result), + 'data_source': 'moz', + } + + logger.warning(f"Moz API returned {response.status_code} for {domain}") + + except Exception as e: + logger.error(f"Moz API error for {domain}: {e}") + + # Fallback to estimation + return await self._estimate_seo_data(domain) + + async def _fetch_top_backlinks( + self, + domain: str, + auth_params: dict, + client: httpx.AsyncClient + ) -> List[Dict[str, Any]]: + """Fetch top backlinks from Moz.""" + try: + response = await client.post( + self.MOZ_LINKS_URL, + params=auth_params, + json={ + 'target': f'http://{domain}/', + 'target_scope': 'root_domain', + 'filter': 'external+nofollow', + 'sort': 'domain_authority', + 'limit': 20 + } + ) + + if response.status_code == 200: + data = response.json() + if 'results' in data: + return [ + { + 'domain': link.get('source', {}).get('root_domain', ''), + 'authority': link.get('source', {}).get('domain_authority', 0), + 'page': link.get('source', {}).get('page', ''), + } + for link in data['results'][:10] + ] + except Exception as e: + logger.error(f"Error fetching backlinks: {e}") + + return [] + + async def _estimate_seo_data(self, domain: str) -> Dict[str, Any]: + """ + Estimate SEO data when no API is available. + + Uses heuristics based on domain characteristics. + """ + # Extract domain parts + parts = domain.split('.') + name = parts[0] if parts else domain + tld = parts[-1] if len(parts) > 1 else '' + + # Estimate domain authority based on characteristics + estimated_da = 10 # Base + + # Short domains tend to have more backlinks + if len(name) <= 4: + estimated_da += 15 + elif len(name) <= 6: + estimated_da += 10 + elif len(name) <= 8: + estimated_da += 5 + + # Premium TLDs + premium_tlds = {'com': 10, 'org': 8, 'net': 5, 'io': 7, 'ai': 8, 'co': 6} + estimated_da += premium_tlds.get(tld, 0) + + # Dictionary words get a boost + common_words = ['tech', 'app', 'data', 'cloud', 'web', 'net', 'hub', 'lab', 'dev'] + if any(word in name.lower() for word in common_words): + estimated_da += 5 + + # Cap at reasonable estimate + estimated_da = min(40, estimated_da) + + # Estimate backlinks based on DA + estimated_backlinks = estimated_da * 50 + estimated_referring = estimated_da * 5 + + return { + 'domain_authority': estimated_da, + 'page_authority': max(0, estimated_da - 5), + 'spam_score': 5, # Assume low spam for estimates + 'total_backlinks': estimated_backlinks, + 'referring_domains': estimated_referring, + 'top_backlinks': [], + 'notable_backlinks': None, + 'has_wikipedia_link': False, + 'has_gov_link': False, + 'has_edu_link': False, + 'has_news_link': False, + 'seo_value_estimate': self._estimate_value(estimated_da), + 'data_source': 'estimated', + } + + def _has_notable_link(self, backlinks: List[Dict], category: str) -> bool: + """Check if backlinks contain notable sources.""" + domains_to_check = self.NOTABLE_DOMAINS.get(category, []) + + for link in backlinks: + link_domain = link.get('domain', '').lower() + for notable in domains_to_check: + if notable in link_domain: + return True + return False + + def _extract_notable(self, backlinks: List[Dict]) -> Optional[str]: + """Extract notable backlink domains as comma-separated string.""" + notable = [] + + for link in backlinks: + domain = link.get('domain', '') + authority = link.get('authority', 0) + + # Include high-authority links + if authority >= 50: + notable.append(domain) + + return ','.join(notable[:10]) if notable else None + + def _calculate_seo_value(self, metrics: Dict) -> float: + """Calculate estimated SEO value in USD.""" + da = metrics.get('domain_authority', 0) + backlinks = metrics.get('external_links_to_root_domain', 0) + + # Base value from DA + if da >= 60: + base_value = 500 + elif da >= 40: + base_value = 200 + elif da >= 20: + base_value = 50 + else: + base_value = 10 + + # Boost for backlinks + link_boost = min(backlinks / 100, 10) * 20 + + return round(base_value + link_boost, 2) + + def _estimate_value(self, da: int) -> float: + """Estimate value based on estimated DA.""" + if da >= 40: + return 200 + elif da >= 30: + return 100 + elif da >= 20: + return 50 + return 20 + + def _format_response(self, data: DomainSEOData) -> Dict[str, Any]: + """Format SEO data for API response.""" + return { + 'domain': data.domain, + 'seo_score': data.seo_score, + 'value_category': data.value_category, + 'metrics': { + 'domain_authority': data.domain_authority, + 'page_authority': data.page_authority, + 'spam_score': data.spam_score, + 'total_backlinks': data.total_backlinks, + 'referring_domains': data.referring_domains, + }, + 'notable_links': { + 'has_wikipedia': data.has_wikipedia_link, + 'has_gov': data.has_gov_link, + 'has_edu': data.has_edu_link, + 'has_news': data.has_news_link, + 'notable_domains': data.notable_backlinks.split(',') if data.notable_backlinks else [], + }, + 'top_backlinks': data.top_backlinks or [], + 'estimated_value': data.seo_value_estimate, + 'data_source': data.data_source, + 'last_updated': data.last_updated.isoformat() if data.last_updated else None, + 'is_estimated': data.data_source == 'estimated', + } + + +# Singleton instance +seo_analyzer = SEOAnalyzerService() + diff --git a/frontend/src/app/command/seo/page.tsx b/frontend/src/app/command/seo/page.tsx new file mode 100644 index 0000000..dbd26da --- /dev/null +++ b/frontend/src/app/command/seo/page.tsx @@ -0,0 +1,496 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { CommandCenterLayout } from '@/components/CommandCenterLayout' +import { PageContainer, StatCard, Badge } from '@/components/PremiumTable' +import { + Search, + Link2, + Globe, + Shield, + TrendingUp, + Loader2, + AlertCircle, + X, + ExternalLink, + Crown, + CheckCircle, + Sparkles, + BookOpen, + Building, + GraduationCap, + Newspaper, + Lock, + Star, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +interface SEOData { + domain: string + seo_score: number + value_category: string + metrics: { + domain_authority: number | null + page_authority: number | null + spam_score: number | null + total_backlinks: number | null + referring_domains: number | null + } + notable_links: { + has_wikipedia: boolean + has_gov: boolean + has_edu: boolean + has_news: boolean + notable_domains: string[] + } + top_backlinks: Array<{ + domain: string + authority: number + page: string + }> + estimated_value: number | null + data_source: string + last_updated: string | null + is_estimated: boolean +} + +export default function SEOPage() { + const { subscription } = useStore() + + const [domain, setDomain] = useState('') + const [loading, setLoading] = useState(false) + const [seoData, setSeoData] = useState(null) + const [error, setError] = useState(null) + const [recentSearches, setRecentSearches] = useState([]) + + const tier = subscription?.tier?.toLowerCase() || 'scout' + const isTycoon = tier === 'tycoon' + + useEffect(() => { + // Load recent searches from localStorage + const saved = localStorage.getItem('seo-recent-searches') + if (saved) { + setRecentSearches(JSON.parse(saved)) + } + }, []) + + const saveRecentSearch = (domain: string) => { + const updated = [domain, ...recentSearches.filter(d => d !== domain)].slice(0, 5) + setRecentSearches(updated) + localStorage.setItem('seo-recent-searches', JSON.stringify(updated)) + } + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault() + if (!domain.trim()) return + + setLoading(true) + setError(null) + setSeoData(null) + + try { + const data = await api.request(`/seo/${encodeURIComponent(domain.trim())}`) + setSeoData(data) + saveRecentSearch(domain.trim().toLowerCase()) + } catch (err: any) { + setError(err.message || 'Failed to analyze domain') + } finally { + setLoading(false) + } + } + + const handleQuickSearch = async (searchDomain: string) => { + setDomain(searchDomain) + setLoading(true) + setError(null) + setSeoData(null) + + try { + const data = await api.request(`/seo/${encodeURIComponent(searchDomain)}`) + setSeoData(data) + } catch (err: any) { + setError(err.message || 'Failed to analyze domain') + } finally { + setLoading(false) + } + } + + const getScoreColor = (score: number) => { + if (score >= 60) return 'text-accent' + if (score >= 40) return 'text-amber-400' + if (score >= 20) return 'text-orange-400' + return 'text-foreground-muted' + } + + const getScoreBg = (score: number) => { + if (score >= 60) return 'bg-accent/10 border-accent/30' + if (score >= 40) return 'bg-amber-500/10 border-amber-500/30' + if (score >= 20) return 'bg-orange-500/10 border-orange-500/30' + return 'bg-foreground/5 border-border' + } + + const formatNumber = (num: number | null) => { + if (num === null) return '-' + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M` + if (num >= 1000) return `${(num / 1000).toFixed(1)}K` + return num.toString() + } + + // Show upgrade prompt for non-Tycoon users + if (!isTycoon) { + return ( + + +
+
+ +
+

Tycoon Feature

+

+ SEO Juice Detector is a premium feature for serious domain investors. + Analyze backlinks, domain authority, and find hidden gems that SEO agencies pay + $100-$500 for — even if the name is "ugly". +

+ +
+
+ +

Backlink Analysis

+

Top referring domains

+
+
+ +

Domain Authority

+

Moz DA/PA scores

+
+
+ +

Notable Links

+

Wikipedia, .gov, .edu

+
+
+ + + + Upgrade to Tycoon + +
+
+
+ ) + } + + return ( + + + {/* Error Message */} + {error && ( +
+ +

{error}

+ +
+ )} + + {/* Search Form */} +
+
+
+ + setDomain(e.target.value)} + placeholder="Enter domain to analyze (e.g., example.com)" + className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-xl + text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent" + /> +
+ +
+ + {/* Recent Searches */} + {recentSearches.length > 0 && !seoData && ( +
+ Recent: + {recentSearches.map((d) => ( + + ))} +
+ )} +
+ + {/* Loading State */} + {loading && ( +
+ +

Analyzing backlinks & authority...

+
+ )} + + {/* Results */} + {seoData && !loading && ( +
+ {/* Header with Score */} +
+
+
+

+ {seoData.domain} +

+
+ + {seoData.data_source === 'moz' ? 'Moz Data' : 'Estimated'} + + {seoData.value_category} +
+
+
+ + {seoData.seo_score} + + SEO Score +
+
+ + {/* Estimated Value */} + {seoData.estimated_value && ( +
+

Estimated SEO Value

+

+ ${seoData.estimated_value.toLocaleString()} +

+

+ Based on domain authority & backlink profile +

+
+ )} +
+ + {/* Metrics Grid */} +
+ + + + + 30 ? '⚠️ High' : '✓ Low'} + /> +
+ + {/* Notable Links */} +
+

Notable Backlinks

+
+
+ +
+

Wikipedia

+

+ {seoData.notable_links.has_wikipedia ? '✓ Found' : 'Not found'} +

+
+
+ +
+ +
+

.gov Links

+

+ {seoData.notable_links.has_gov ? '✓ Found' : 'Not found'} +

+
+
+ +
+ +
+

.edu Links

+

+ {seoData.notable_links.has_edu ? '✓ Found' : 'Not found'} +

+
+
+ +
+ +
+

News Sites

+

+ {seoData.notable_links.has_news ? '✓ Found' : 'Not found'} +

+
+
+
+ + {/* Notable Domains List */} + {seoData.notable_links.notable_domains.length > 0 && ( +
+

High-authority referring domains:

+
+ {seoData.notable_links.notable_domains.map((d) => ( + + {d} + + ))} +
+
+ )} +
+ + {/* Top Backlinks */} + {seoData.top_backlinks.length > 0 && ( +
+

Top Backlinks

+
+ {seoData.top_backlinks.map((link, idx) => ( +
+
+
= 60 ? "bg-accent/10 text-accent" : + link.authority >= 40 ? "bg-amber-500/10 text-amber-400" : + "bg-foreground/5 text-foreground-muted" + )}> + {link.authority} +
+
+

{link.domain}

+ {link.page && ( +

{link.page}

+ )} +
+
+ + + +
+ ))} +
+
+ )} + + {/* Data Source Note */} + {seoData.is_estimated && ( +
+

+ + This data is estimated based on domain characteristics. + For live Moz data, configure MOZ_ACCESS_ID and MOZ_SECRET_KEY in the backend. +

+
+ )} +
+ )} + + {/* Empty State */} + {!seoData && !loading && !error && ( +
+ +

SEO Juice Detector

+

+ Enter a domain above to analyze its backlink profile, domain authority, + and find hidden SEO value that others miss. +

+
+ )} +
+
+ ) +} + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3baee3e..756714b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { Sparkles, Tag, Target, + Link2, } from 'lucide-react' import { useState, useEffect } from 'react' import clsx from 'clsx' @@ -115,6 +116,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S icon: Target, badge: null, }, + { + href: '/command/seo', + label: 'SEO Juice', + icon: Link2, + badge: 'Tycoon', + }, ] const bottomItems = [