| """ |
| Competitor Analysis Routes |
| |
| Provides AI-powered competitor analysis using web scraping and LLM integration. |
| """ |
|
|
| import logging |
| from datetime import datetime, timedelta |
| from typing import List, Optional |
| from uuid import uuid4 |
|
|
| from fastapi import Depends, HTTPException, Request |
| from pydantic import BaseModel, ConfigDict, Field |
| from sqlalchemy.orm import Session |
|
|
| from core.base_routes import BaseAPIRouter |
| from core.database import get_db |
| from core.llm_service import LLMService |
| from core.models import User, CompetitorAnalysis, OAuthToken |
| from core.security_dependencies import get_current_user |
| from integrations.notion_service import NotionService |
|
|
| router = BaseAPIRouter(prefix="/api/v1/analysis", tags=["competitor-analysis"]) |
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| class CompetitorAnalysisRequest(BaseModel): |
| """Competitor analysis request""" |
| competitors: List[str] = Field(..., min_length=1, max_length=10, description="List of competitor names/URLs") |
| analysis_depth: str = Field("standard", description="Analysis depth: basic, standard, comprehensive") |
| focus_areas: Optional[List[str]] = Field( |
| default=["products", "pricing", "marketing", "strengths", "weaknesses"], |
| description="Areas to focus analysis on" |
| ) |
| notion_database_id: Optional[str] = Field(None, description="Notion database ID for results") |
|
|
| model_config = ConfigDict(extra="allow") |
|
|
|
|
| class CompetitorInsight(BaseModel): |
| """Individual competitor insight""" |
| competitor: str |
| strengths: List[str] |
| weaknesses: List[str] |
| market_position: str |
| key_products: List[str] |
| pricing_strategy: str |
| marketing_tactics: List[str] |
| recent_news: List[str] |
|
|
|
|
| class CompetitorAnalysisResponse(BaseModel): |
| """Competitor analysis response""" |
| analysis_id: str |
| status: str |
| insights: dict[str, CompetitorInsight] |
| comparison_matrix: dict |
| recommendations: List[str] |
| created_at: datetime |
|
|
|
|
| async def fetch_competitor_data(competitor: str, focus_areas: List[str]) -> dict: |
| """ |
| Fetch data about a competitor using web scraping and APIs. |
| |
| In production, this would: |
| - Scrape the competitor's website |
| - Query business databases (Crunchbase, LinkedIn) |
| - Analyze social media presence |
| - Check recent news and press releases |
| """ |
| try: |
| import httpx |
|
|
| |
| |
| competitor_lower = competitor.lower() |
|
|
| |
| if competitor.startswith("http"): |
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get(competitor, timeout=10.0) |
| if response.status_code == 200: |
| |
| html = response.text |
| |
| title_start = html.find("<title>") + 7 |
| title_end = html.find("</title>", title_start) |
| title = html[title_start:title_end] if title_start > 6 and title_end > title_start else competitor |
|
|
| return { |
| "name": title.strip(), |
| "url": competitor, |
| "data_source": "web_scrape", |
| } |
| except Exception as e: |
| logger.warning(f"Failed to scrape {competitor}: {e}") |
|
|
| |
| return { |
| "name": competitor, |
| "url": f"https://www.{competitor_lower.replace(' ', '')}.com", |
| "data_source": "simulated", |
| "note": "Replace with actual scraping in production" |
| } |
|
|
| except Exception as e: |
| logger.error(f"Error fetching competitor data for {competitor}: {e}") |
| return { |
| "name": competitor, |
| "url": None, |
| "data_source": "error", |
| "error": str(e) |
| } |
|
|
|
|
| async def analyze_with_llm(competitor_data: dict, focus_areas: List[str], db: Session) -> CompetitorInsight: |
| """ |
| Analyze competitor data using LLM to generate insights. |
| |
| Uses LLMService for cost-optimized provider selection with usage tracking. |
| Falls back to simulated insights if LLM fails. |
| """ |
| competitor_name = competitor_data.get("name", "Unknown") |
|
|
| |
| prompt = f""" |
| Analyze the competitor "{competitor_name}" and provide strategic insights. |
| |
| Focus Areas: {', '.join(focus_areas)} |
| Available Data: {competitor_data} |
| |
| Provide specific, actionable insights including: |
| - Key competitive advantages (strengths) |
| - Vulnerabilities and areas for improvement (weaknesses) |
| - Current market position and strategy |
| - Main products or services |
| - Pricing approach and strategy |
| - Marketing and sales tactics |
| - Recent notable developments or news |
| |
| Be specific and data-driven. Avoid generic statements. |
| """ |
|
|
| system_instruction = """You are an expert business analyst and competitive intelligence specialist. |
| You provide detailed, specific, and actionable competitor insights. |
| Your analysis is data-driven, strategic, and focused on business implications.""" |
|
|
| try: |
| |
| llm = LLMService(workspace_id="default", db=db) |
| result = await llm.generate_structured( |
| prompt=prompt, |
| system_instruction=system_instruction, |
| response_model=CompetitorInsight, |
| temperature=0.3, |
| task_type="analysis", |
| agent_id=None |
| ) |
|
|
| if result: |
| logger.info(f"Generated LLM insights for competitor: {competitor_name}") |
| return result |
| else: |
| logger.warning(f"LLM returned None for {competitor_name}, using fallback") |
|
|
| except Exception as e: |
| logger.error(f"LLM analysis failed for {competitor_name}: {e}") |
|
|
| |
| logger.info(f"Using fallback insights for competitor: {competitor_name}") |
| return _generate_fallback_insights(competitor_name, focus_areas) |
|
|
|
|
| def _generate_fallback_insights(competitor_name: str, focus_areas: List[str]) -> CompetitorInsight: |
| """Generate fallback insights when LLM is unavailable.""" |
| return CompetitorInsight( |
| competitor=competitor_name, |
| strengths=[ |
| f"Established market presence", |
| f"Brand recognition in industry", |
| f"Diverse product offerings", |
| ], |
| weaknesses=[ |
| f"Limited recent innovation visible", |
| f"Pricing may not be competitive", |
| f"Slower technology adoption", |
| ], |
| market_position=f"Established player competing in key segments", |
| key_products=[ |
| f"Core product suite", |
| f"Enterprise solutions", |
| f"Cloud-based services", |
| ], |
| pricing_strategy="Market-aligned pricing with enterprise discounts", |
| marketing_tactics=[ |
| "Digital marketing campaigns", |
| "Industry partnerships", |
| "Content marketing strategy", |
| ], |
| recent_news=[ |
| f"{competitor_name} continues market operations", |
| f"Product line expansions ongoing", |
| f"Strategic partnerships maintained", |
| ] |
| ) |
|
|
|
|
| def generate_comparison_matrix(insights: dict[str, CompetitorInsight]) -> dict: |
| """Generate a comparison matrix across all competitors.""" |
| competitors = list(insights.keys()) |
|
|
| comparison = { |
| "pricing": {}, |
| "market_position": {}, |
| "innovation": {}, |
| "strengths_count": {}, |
| "weaknesses_count": {}, |
| } |
|
|
| for comp in competitors: |
| insight = insights[comp] |
|
|
| |
| comparison["strengths_count"][comp] = len(insight.strengths) |
| comparison["weaknesses_count"][comp] = len(insight.weaknesses) |
|
|
| |
| pricing = insight.pricing_strategy.lower() |
| if "premium" in pricing: |
| comparison["pricing"][comp] = "Premium" |
| elif "budget" in pricing or "low" in pricing: |
| comparison["pricing"][comp] = "Budget" |
| else: |
| comparison["pricing"][comp] = "Mid-range" |
|
|
| |
| market = insight.market_position.lower() |
| if "leader" in market or "dominant" in market: |
| comparison["market_position"][comp] = "Leader" |
| elif "challenger" in market or "growing" in market: |
| comparison["market_position"][comp] = "Challenger" |
| else: |
| comparison["market_position"][comp] = "Follower" |
|
|
| |
| comparison["innovation"][comp] = "Moderate" if len(insight.recent_news) > 2 else "Low" |
|
|
| return comparison |
|
|
|
|
| def generate_recommendations(insights: dict[str, CompetitorInsight], comparison: dict) -> List[str]: |
| """Generate strategic recommendations based on analysis.""" |
| recommendations = [] |
|
|
| |
| pricing_values = list(comparison["pricing"].values()) |
| if "Premium" in pricing_values and "Budget" in pricing_values: |
| recommendations.append( |
| "Consider mid-tier pricing strategy to capture customers between premium and budget competitors" |
| ) |
|
|
| |
| market_positions = list(comparison["market_position"].values()) |
| if market_positions.count("Follower") >= len(market_positions) / 2: |
| recommendations.append( |
| "Market has many followers - consider differentiation strategy to become a challenger" |
| ) |
|
|
| |
| all_strengths = [] |
| for insight in insights.values(): |
| all_strengths.extend(insight.strengths) |
|
|
| if "brand recognition" in " ".join(all_strengths).lower(): |
| recommendations.append( |
| "Invest in brand building to compete with established players' strong brand recognition" |
| ) |
|
|
| |
| innovation_scores = list(comparison["innovation"].values()) |
| if innovation_scores.count("Low") >= len(innovation_scores) / 2: |
| recommendations.append( |
| "Opportunity to differentiate through innovation - many competitors show low innovation activity" |
| ) |
|
|
| |
| if not recommendations: |
| recommendations.append( |
| "Focus on unique value proposition and customer experience to differentiate from competitors" |
| ) |
|
|
| return recommendations |
|
|
|
|
| async def export_competitor_analysis_to_notion( |
| analysis: CompetitorAnalysis, |
| notion_token: str |
| ) -> Optional[str]: |
| """ |
| Export competitor analysis to Notion database. |
| |
| Creates a page in the Notion database with the competitor analysis summary. |
| |
| Args: |
| analysis: CompetitorAnalysis database model |
| notion_token: Notion API access token |
| |
| Returns: |
| Notion page ID if successful, None otherwise |
| """ |
| try: |
| notion = NotionService(access_token=notion_token) |
|
|
| |
| parent = {"type": "database_id", "database_id": analysis.notion_database_id} |
|
|
| |
| competitors_str = ", ".join(analysis.competitors) |
|
|
| properties = { |
| "Competitors": { |
| "title": [ |
| { |
| "text": { |
| "content": f"Competitor Analysis: {competitors_str}" |
| } |
| } |
| ] |
| }, |
| "Analysis Depth": { |
| "select": { |
| "name": analysis.analysis_depth.capitalize() |
| } |
| }, |
| "Status": { |
| "select": { |
| "name": analysis.status.capitalize() |
| } |
| }, |
| "Created": { |
| "date": { |
| "start": analysis.created_at.isoformat() |
| } |
| } |
| } |
|
|
| |
| children = [] |
|
|
| |
| if analysis.comparison_matrix: |
| children.append({ |
| "object": "block", |
| "type": "heading_2", |
| "heading_2": { |
| "rich_text": [{"type": "text", "text": {"content": "📊 Comparison Matrix"}}] |
| } |
| }) |
| for category, values in analysis.comparison_matrix.items(): |
| children.append({ |
| "object": "block", |
| "type": "heading_3", |
| "heading_3": { |
| "rich_text": [{"type": "text", "text": {"content": category.capitalize()}}] |
| } |
| }) |
| for comp, value in values.items(): |
| children.append({ |
| "object": "block", |
| "type": "bulleted_list_item", |
| "bulleted_list_item": { |
| "rich_text": [ |
| {"type": "text", "text": {"content": f"{comp}: "}}, |
| {"type": "text", "text": {"content": str(value)}, "bold": True} |
| ] |
| } |
| }) |
|
|
| |
| if analysis.recommendations: |
| children.append({ |
| "object": "block", |
| "type": "heading_2", |
| "heading_2": { |
| "rich_text": [{"type": "text", "text": {"content": "💡 Recommendations"}}] |
| } |
| }) |
| for i, rec in enumerate(analysis.recommendations, 1): |
| children.append({ |
| "object": "block", |
| "type": "numbered_list_item", |
| "numbered_list_item": { |
| "rich_text": [{"type": "text", "text": {"content": rec}}] |
| } |
| }) |
|
|
| |
| result = notion.create_page(parent, properties, children) |
|
|
| if result and "id" in result: |
| logger.info(f"Competitor analysis exported to Notion: page_id={result['id']}") |
| return result["id"] |
| else: |
| logger.warning("Notion page creation returned no ID") |
| return None |
|
|
| except Exception as e: |
| logger.error(f"Failed to export competitor analysis to Notion: {e}") |
| return None |
|
|
|
|
| @router.post("/competitors", response_model=CompetitorAnalysisResponse) |
| async def analyze_competitors( |
| request: Request, |
| payload: CompetitorAnalysisRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """ |
| Analyze competitors using AI and web scraping. |
| |
| Fetches data about each competitor, analyzes using LLM, |
| and generates actionable insights and recommendations. |
| |
| Focus Areas: |
| - products: Product offerings and features |
| - pricing: Pricing strategies and positioning |
| - marketing: Marketing channels and tactics |
| - strengths: Competitive advantages |
| - weaknesses: Areas for improvement |
| |
| Uses BYOK handler for cost-optimized LLM integration with automatic fallback. |
| |
| Results are cached for 7 days to avoid repeated analysis. |
| """ |
| try: |
|
|
| |
| if not payload.competitors or len(payload.competitors) == 0: |
| raise HTTPException( |
| status_code=400, |
| detail="At least one competitor must be specified" |
| ) |
|
|
| if len(payload.competitors) > 10: |
| raise HTTPException( |
| status_code=400, |
| detail="Maximum 10 competitors allowed per analysis" |
| ) |
|
|
| |
| valid_depths = ["basic", "standard", "comprehensive"] |
| if payload.analysis_depth not in valid_depths: |
| raise HTTPException( |
| status_code=400, |
| detail=f"Invalid analysis depth. Must be one of: {', '.join(valid_depths)}" |
| ) |
|
|
| |
| cache_expiry = datetime.utcnow() - timedelta(days=7) |
| cached_analysis = db.query(CompetitorAnalysis).filter( |
| CompetitorAnalysis.user_id == current_user.id, |
| CompetitorAnalysis.competitors == payload.competitors, |
| CompetitorAnalysis.analysis_depth == payload.analysis_depth, |
| CompetitorAnalysis.created_at >= cache_expiry |
| ).first() |
|
|
| if cached_analysis: |
| logger.info(f"Returning cached analysis: {cached_analysis.id}") |
| |
| insights = { |
| k: CompetitorInsight(**v) if isinstance(v, dict) else v |
| for k, v in cached_analysis.insights.items() |
| } |
| return CompetitorAnalysisResponse( |
| analysis_id=cached_analysis.id, |
| status="cached", |
| insights=insights, |
| comparison_matrix=cached_analysis.comparison_matrix, |
| recommendations=cached_analysis.recommendations, |
| created_at=cached_analysis.created_at |
| ) |
|
|
| |
| analysis_id = str(uuid4()) |
|
|
| logger.info( |
| f"Starting competitor analysis: user={current_user.id}, " |
| f"analysis_id={analysis_id}, " |
| f"competitors={len(payload.competitors)}" |
| ) |
|
|
| |
| insights = {} |
| for competitor in payload.competitors: |
| try: |
| |
| competitor_data = await fetch_competitor_data(competitor, payload.focus_areas) |
|
|
| |
| insight = await analyze_with_llm(competitor_data, payload.focus_areas, db) |
| insights[competitor] = insight |
|
|
| except Exception as e: |
| logger.error(f"Failed to analyze competitor {competitor}: {e}") |
| |
| insights[competitor] = CompetitorInsight( |
| competitor=competitor, |
| strengths=[], |
| weaknesses=[f"Analysis failed: {str(e)}"], |
| market_position="Unknown", |
| key_products=[], |
| pricing_strategy="Unknown", |
| marketing_tactics=[], |
| recent_news=[] |
| ) |
|
|
| |
| comparison_matrix = generate_comparison_matrix(insights) |
|
|
| |
| recommendations = generate_recommendations(insights, comparison_matrix) |
|
|
| |
| insights_dict = {k: v.model_dump() if hasattr(v, 'model_dump') else v.__dict__ for k, v in insights.items()} |
|
|
| |
| competitor_analysis = CompetitorAnalysis( |
| id=analysis_id, |
| user_id=current_user.id, |
| competitors=payload.competitors, |
| analysis_depth=payload.analysis_depth, |
| focus_areas=payload.focus_areas, |
| insights=insights_dict, |
| comparison_matrix=comparison_matrix, |
| recommendations=recommendations, |
| notion_database_id=payload.notion_database_id, |
| notion_page_id=None, |
| status="complete", |
| cache_expiry=datetime.utcnow() + timedelta(days=7) |
| ) |
|
|
| db.add(competitor_analysis) |
| db.commit() |
|
|
| |
| logger.info( |
| f"Competitor analysis complete: analysis_id={analysis_id}, " |
| f"competitors_analyzed={len(insights)}, " |
| f"recommendations={len(recommendations)}" |
| ) |
|
|
| |
| if payload.notion_database_id: |
| logger.info( |
| f"Notion export requested: database_id={payload.notion_database_id}" |
| ) |
|
|
| |
| notion_token_record = db.query(OAuthToken).filter( |
| OAuthToken.user_id == current_user.id, |
| OAuthToken.provider == "notion", |
| OAuthToken.status == "active" |
| ).first() |
|
|
| if notion_token_record and notion_token_record.access_token: |
| notion_page_id = await export_competitor_analysis_to_notion( |
| analysis=competitor_analysis, |
| notion_token=notion_token_record.access_token |
| ) |
|
|
| if notion_page_id: |
| |
| competitor_analysis.notion_page_id = notion_page_id |
| db.commit() |
| logger.info(f"Competitor analysis exported to Notion: page_id={notion_page_id}") |
| else: |
| logger.warning("Notion export failed, but analysis was saved successfully") |
| else: |
| logger.warning(f"No active Notion token found for user {current_user.id}, skipping export") |
|
|
| return CompetitorAnalysisResponse( |
| analysis_id=analysis_id, |
| status="complete", |
| insights=insights, |
| comparison_matrix=comparison_matrix, |
| recommendations=recommendations, |
| created_at=competitor_analysis.created_at |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Competitor analysis failed: {e}", exc_info=True) |
| raise HTTPException( |
| status_code=500, |
| detail=f"Failed to analyze competitors: {str(e)}" |
| ) |
|
|
|
|
| @router.get("/competitors/{analysis_id}") |
| async def get_analysis_result( |
| analysis_id: str, |
| request: Request, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """ |
| Retrieve a previously generated competitor analysis. |
| """ |
| |
| analysis = db.query(CompetitorAnalysis).filter( |
| CompetitorAnalysis.id == analysis_id |
| ).first() |
|
|
| if not analysis: |
| raise HTTPException( |
| status_code=404, |
| detail=f"Competitor analysis with ID '{analysis_id}' not found" |
| ) |
|
|
| |
| if analysis.user_id != current_user.id: |
| raise HTTPException( |
| status_code=403, |
| detail="You do not have permission to access this analysis" |
| ) |
|
|
| |
| if analysis.cache_expiry and analysis.cache_expiry < datetime.utcnow(): |
| analysis.status = "expired" |
| db.commit() |
|
|
| |
| insights = { |
| k: CompetitorInsight(**v) if isinstance(v, dict) else v |
| for k, v in analysis.insights.items() |
| } |
|
|
| return CompetitorAnalysisResponse( |
| analysis_id=analysis.id, |
| status=analysis.status, |
| insights=insights, |
| comparison_matrix=analysis.comparison_matrix, |
| recommendations=analysis.recommendations, |
| created_at=analysis.created_at |
| ) |
|
|
|
|
| @router.get("/competitors") |
| async def list_analyses( |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db), |
| limit: int = 20, |
| offset: int = 0 |
| ): |
| """ |
| List all competitor analyses for the current user. |
| """ |
| |
| analyses = db.query(CompetitorAnalysis).filter( |
| CompetitorAnalysis.user_id == current_user.id |
| ).order_by( |
| CompetitorAnalysis.created_at.desc() |
| ).offset(offset).limit(limit).all() |
|
|
| total = db.query(CompetitorAnalysis).filter( |
| CompetitorAnalysis.user_id == current_user.id |
| ).count() |
|
|
| return { |
| "analyses": [ |
| { |
| "analysis_id": analysis.id, |
| "competitors": analysis.competitors, |
| "analysis_depth": analysis.analysis_depth, |
| "status": analysis.status, |
| "created_at": analysis.created_at, |
| "cache_expiry": analysis.cache_expiry |
| } |
| for analysis in analyses |
| ], |
| "total": total, |
| "limit": limit, |
| "offset": offset |
| } |
|
|
|
|
| @router.delete("/competitors/{analysis_id}") |
| async def delete_analysis( |
| analysis_id: str, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """ |
| Delete a competitor analysis. |
| """ |
| |
| analysis = db.query(CompetitorAnalysis).filter( |
| CompetitorAnalysis.id == analysis_id |
| ).first() |
|
|
| if not analysis: |
| raise HTTPException( |
| status_code=404, |
| detail=f"Competitor analysis with ID '{analysis_id}' not found" |
| ) |
|
|
| |
| if analysis.user_id != current_user.id: |
| raise HTTPException( |
| status_code=403, |
| detail="You do not have permission to delete this analysis" |
| ) |
|
|
| |
| db.delete(analysis) |
| db.commit() |
|
|
| logger.info(f"Competitor analysis deleted: analysis_id={analysis_id}") |
|
|
| return { |
| "success": True, |
| "message": "Competitor analysis deleted successfully" |
| } |
|
|
|
|
| @router.get("/competitors/templates") |
| async def list_analysis_templates(): |
| """ |
| List available competitor analysis templates. |
| |
| Pre-configured focus areas for different industries/use cases. |
| """ |
| templates = { |
| "ecommerce": { |
| "name": "E-commerce", |
| "focus_areas": ["products", "pricing", "shipping", "user_experience", "reviews"], |
| "description": "Analyze e-commerce competitors" |
| }, |
| "saas": { |
| "name": "SaaS", |
| "focus_areas": ["features", "pricing", "integration", "support", "security"], |
| "description": "Analyze software-as-a-service competitors" |
| }, |
| "retail": { |
| "name": "Retail", |
| "focus_areas": ["products", "pricing", "locations", "inventory", "loyalty"], |
| "description": "Analyze retail competitors" |
| }, |
| "agency": { |
| "name": "Agency/Services", |
| "focus_areas": ["services", "pricing", "portfolio", "reputation", "case_studies"], |
| "description": "Analyze service-based business competitors" |
| } |
| } |
|
|
| return { |
| "templates": templates, |
| "total": len(templates) |
| } |
|
|