""" Competitor Monitoring Service for NordaBiz ========================================== Discovers and monitors competitors via Google Places API. Tracks changes in ratings, reviews, and profile activity. Author: NordaBiz Development Team Created: 2026-02-06 """ import os import json import logging from datetime import datetime, date, timedelta from typing import Optional, Dict, List, Any from decimal import Decimal from sqlalchemy.orm import Session from database import ( Company, CompanyCompetitor, CompetitorSnapshot, CompanyWebsiteAnalysis, SessionLocal ) try: from google_places_service import GooglePlacesService except ImportError: GooglePlacesService = None logger = logging.getLogger(__name__) class CompetitorMonitoringService: """Discovers and monitors competitors for companies.""" def __init__(self, db: Session): self.db = db self._places_service = None @property def places_service(self) -> Optional['GooglePlacesService']: """Lazy-init Google Places service.""" if self._places_service is None and GooglePlacesService: try: self._places_service = GooglePlacesService() except ValueError: logger.warning("Google Places API key not configured") return self._places_service def discover_competitors(self, company_id: int, max_results: int = 10) -> List[Dict[str, Any]]: """ Find competitors via Google Places Nearby Search. Uses the company's Google Place data to find similar businesses nearby. """ if not self.places_service: logger.error("Google Places API not available for competitor discovery") return [] company = self.db.query(Company).filter(Company.id == company_id).first() if not company: raise ValueError(f"Company {company_id} not found") # Get company's Google location analysis = self.db.query(CompanyWebsiteAnalysis).filter( CompanyWebsiteAnalysis.company_id == company_id ).first() place_id = analysis.google_place_id if analysis else None if not place_id: logger.warning(f"Company {company_id} has no Google Place ID, searching by name") # Search for the company first place = self.places_service.search_place( f"{company.name} {company.address_city or 'Wejherowo'}" ) if not place: return [] place_id = place.get('id', '').replace('places/', '') # Get place details to get location place_data = self.places_service.get_place_details(place_id) if not place_data: return [] location = place_data.get('location', {}) latitude = location.get('latitude') longitude = location.get('longitude') primary_type = place_data.get('primaryType') if not latitude or not longitude: logger.error(f"No location data for company {company_id}") return [] # Search nearby for similar businesses included_types = [primary_type] if primary_type else None nearby = self.places_service.search_nearby( latitude=latitude, longitude=longitude, radius=5000.0, included_types=included_types, max_results=max_results + 1 # +1 because our own business may appear ) # Filter out own business and save competitors competitors = [] for place in nearby: competitor_place_id = place.get('id', '').replace('places/', '') if competitor_place_id == place_id: continue # Skip own business display_name = place.get('displayName', {}) name = display_name.get('text', '') if isinstance(display_name, dict) else str(display_name) competitor_data = { 'competitor_place_id': competitor_place_id, 'competitor_name': name, 'competitor_address': place.get('formattedAddress', ''), 'competitor_rating': place.get('rating'), 'competitor_review_count': place.get('userRatingCount'), 'competitor_category': place.get('primaryType', ''), 'competitor_website': place.get('websiteUri', ''), } competitors.append(competitor_data) if len(competitors) >= max_results: break return competitors def save_competitors(self, company_id: int, competitors: List[Dict]) -> int: """Save discovered competitors to database. Returns count saved.""" saved = 0 for comp_data in competitors: existing = self.db.query(CompanyCompetitor).filter( CompanyCompetitor.company_id == company_id, CompanyCompetitor.competitor_place_id == comp_data['competitor_place_id'] ).first() if existing: # Update existing existing.competitor_name = comp_data.get('competitor_name') or existing.competitor_name existing.competitor_rating = comp_data.get('competitor_rating') or existing.competitor_rating existing.competitor_review_count = comp_data.get('competitor_review_count') or existing.competitor_review_count existing.updated_at = datetime.now() else: # Create new competitor = CompanyCompetitor( company_id=company_id, competitor_place_id=comp_data['competitor_place_id'], competitor_name=comp_data.get('competitor_name'), competitor_address=comp_data.get('competitor_address'), competitor_rating=comp_data.get('competitor_rating'), competitor_review_count=comp_data.get('competitor_review_count'), competitor_category=comp_data.get('competitor_category'), competitor_website=comp_data.get('competitor_website'), added_by='auto', ) self.db.add(competitor) saved += 1 self.db.commit() return saved def take_snapshot(self, competitor_id: int) -> Optional[CompetitorSnapshot]: """ Take a snapshot of a competitor's current state. Compares with previous snapshot to detect changes. """ competitor = self.db.query(CompanyCompetitor).filter( CompanyCompetitor.id == competitor_id ).first() if not competitor or not competitor.is_active: return None if not self.places_service: logger.error("Google Places API not available for snapshots") return None # Fetch current data from Google place_data = self.places_service.get_place_details(competitor.competitor_place_id) if not place_data: logger.warning(f"Could not fetch data for competitor {competitor.competitor_name}") return None today = date.today() # Check if snapshot already exists for today existing = self.db.query(CompetitorSnapshot).filter( CompetitorSnapshot.competitor_id == competitor_id, CompetitorSnapshot.snapshot_date == today ).first() if existing: logger.info(f"Snapshot already exists for competitor {competitor_id} on {today}") return existing # Build snapshot data photos = place_data.get('photos', []) display_name = place_data.get('displayName', {}) snapshot_data = { 'name': display_name.get('text', '') if isinstance(display_name, dict) else str(display_name), 'address': place_data.get('formattedAddress', ''), 'rating': place_data.get('rating'), 'review_count': place_data.get('userRatingCount', 0), 'photo_count': len(photos), 'business_status': place_data.get('businessStatus', ''), 'types': place_data.get('types', []), 'website': place_data.get('websiteUri', ''), } # Get previous snapshot for change detection previous = self.db.query(CompetitorSnapshot).filter( CompetitorSnapshot.competitor_id == competitor_id ).order_by(CompetitorSnapshot.snapshot_date.desc()).first() changes = {} if previous: prev_data = previous.data or {} if snapshot_data.get('rating') != prev_data.get('rating'): changes['rating'] = { 'old': prev_data.get('rating'), 'new': snapshot_data.get('rating') } if snapshot_data.get('review_count', 0) != prev_data.get('review_count', 0): changes['review_count'] = { 'old': prev_data.get('review_count', 0), 'new': snapshot_data.get('review_count', 0), 'delta': (snapshot_data.get('review_count', 0) or 0) - (prev_data.get('review_count', 0) or 0) } if snapshot_data.get('photo_count', 0) != prev_data.get('photo_count', 0): changes['photo_count'] = { 'old': prev_data.get('photo_count', 0), 'new': snapshot_data.get('photo_count', 0) } # Create snapshot snapshot = CompetitorSnapshot( competitor_id=competitor_id, snapshot_date=today, rating=snapshot_data.get('rating'), review_count=snapshot_data.get('review_count'), photo_count=snapshot_data.get('photo_count'), has_website=bool(snapshot_data.get('website')), has_description=bool(place_data.get('editorialSummary')), data=snapshot_data, changes=changes if changes else None, ) self.db.add(snapshot) # Update competitor record with latest data competitor.competitor_rating = snapshot_data.get('rating') competitor.competitor_review_count = snapshot_data.get('review_count') competitor.updated_at = datetime.now() self.db.commit() self.db.refresh(snapshot) if changes: logger.info(f"Changes detected for {competitor.competitor_name}: {list(changes.keys())}") return snapshot def take_all_snapshots(self, company_id: int) -> Dict[str, Any]: """Take snapshots for all active competitors of a company.""" competitors = self.db.query(CompanyCompetitor).filter( CompanyCompetitor.company_id == company_id, CompanyCompetitor.is_active == True ).all() results = { 'total': len(competitors), 'success': 0, 'failed': 0, 'changes_detected': 0, } for competitor in competitors: try: snapshot = self.take_snapshot(competitor.id) if snapshot: results['success'] += 1 if snapshot.changes: results['changes_detected'] += 1 else: results['failed'] += 1 except Exception as e: logger.error(f"Snapshot failed for competitor {competitor.id}: {e}") results['failed'] += 1 return results def get_changes_report(self, company_id: int, days: int = 30) -> List[Dict[str, Any]]: """Get competitor changes report for the last N days.""" since = date.today() - timedelta(days=days) competitors = self.db.query(CompanyCompetitor).filter( CompanyCompetitor.company_id == company_id, CompanyCompetitor.is_active == True ).all() report = [] for competitor in competitors: snapshots = self.db.query(CompetitorSnapshot).filter( CompetitorSnapshot.competitor_id == competitor.id, CompetitorSnapshot.snapshot_date >= since ).order_by(CompetitorSnapshot.snapshot_date.asc()).all() changes = [] for snap in snapshots: if snap.changes: changes.append({ 'date': snap.snapshot_date.isoformat(), 'changes': snap.changes, }) report.append({ 'competitor_id': competitor.id, 'competitor_name': competitor.competitor_name, 'current_rating': float(competitor.competitor_rating) if competitor.competitor_rating else None, 'current_review_count': competitor.competitor_review_count, 'snapshots_count': len(snapshots), 'changes': changes, }) return report def get_comparison(self, company_id: int) -> Dict[str, Any]: """Compare company with its competitors.""" company = self.db.query(Company).filter(Company.id == company_id).first() if not company: return {} analysis = self.db.query(CompanyWebsiteAnalysis).filter( CompanyWebsiteAnalysis.company_id == company_id ).first() company_data = { 'name': company.name, 'rating': float(analysis.google_rating) if analysis and analysis.google_rating else None, 'review_count': analysis.google_reviews_count if analysis else 0, 'photo_count': analysis.google_photos_count if analysis else 0, } competitors = self.db.query(CompanyCompetitor).filter( CompanyCompetitor.company_id == company_id, CompanyCompetitor.is_active == True ).all() competitor_data = [] for comp in competitors: competitor_data.append({ 'name': comp.competitor_name, 'rating': float(comp.competitor_rating) if comp.competitor_rating else None, 'review_count': comp.competitor_review_count or 0, }) # Calculate averages ratings = [c['rating'] for c in competitor_data if c['rating']] review_counts = [c['review_count'] for c in competitor_data] return { 'company': company_data, 'competitors': competitor_data, 'avg_competitor_rating': round(sum(ratings) / len(ratings), 1) if ratings else None, 'avg_competitor_reviews': round(sum(review_counts) / len(review_counts)) if review_counts else 0, 'company_rank_by_rating': self._calculate_rank(company_data.get('rating'), ratings), 'company_rank_by_reviews': self._calculate_rank(company_data.get('review_count'), review_counts), } @staticmethod def _calculate_rank(value, others: List) -> Optional[int]: """Calculate rank (1 = best) among competitors.""" if value is None or not others: return None all_values = sorted([value] + [v for v in others if v is not None], reverse=True) try: return all_values.index(value) + 1 except ValueError: return None