feat: Add new services, scripts, and competitor dashboard
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- google_places_service.py: Google Places API integration - competitor_monitoring_service.py: Competitor tracking service - scripts/competitor_monitor_cron.py, scripts/generate_audit_report.py - blueprints/admin/routes_competitors.py, templates/admin/competitor_dashboard.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
42ddeabf2a
commit
02696c59a4
157
blueprints/admin/routes_competitors.py
Normal file
157
blueprints/admin/routes_competitors.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""
|
||||
Admin Competitor Monitoring Routes
|
||||
===================================
|
||||
|
||||
Dashboard for viewing and managing competitor tracking.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import render_template, request, jsonify, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
from database import (
|
||||
SessionLocal, Company, CompanyCompetitor, CompetitorSnapshot,
|
||||
GBPAudit, SystemRole
|
||||
)
|
||||
from utils.decorators import role_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/competitors')
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def admin_competitors():
|
||||
"""
|
||||
Admin dashboard for competitor monitoring overview.
|
||||
|
||||
Displays:
|
||||
- List of companies with active competitor tracking
|
||||
- Competitor count per company
|
||||
- Recent changes detected
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get companies that have competitors tracked
|
||||
companies_with_competitors = (
|
||||
db.query(Company, db.query(CompanyCompetitor)
|
||||
.filter(CompanyCompetitor.company_id == Company.id,
|
||||
CompanyCompetitor.is_active == True)
|
||||
.count().label('competitor_count'))
|
||||
.filter(Company.status == 'active')
|
||||
.all()
|
||||
)
|
||||
|
||||
# Simpler approach: get all active competitors grouped by company
|
||||
competitors = db.query(CompanyCompetitor).filter(
|
||||
CompanyCompetitor.is_active == True
|
||||
).all()
|
||||
|
||||
# Group by company
|
||||
company_ids = set(c.company_id for c in competitors)
|
||||
companies = db.query(Company).filter(
|
||||
Company.id.in_(company_ids),
|
||||
Company.status == 'active'
|
||||
).all() if company_ids else []
|
||||
|
||||
company_data = []
|
||||
for company in companies:
|
||||
company_competitors = [c for c in competitors if c.company_id == company.id]
|
||||
|
||||
# Get GBP audit for this company (for comparison)
|
||||
gbp_audit = db.query(GBPAudit).filter(
|
||||
GBPAudit.company_id == company.id
|
||||
).order_by(GBPAudit.audit_date.desc()).first()
|
||||
|
||||
company_data.append({
|
||||
'company': company,
|
||||
'competitor_count': len(company_competitors),
|
||||
'competitors': company_competitors,
|
||||
'gbp_audit': gbp_audit,
|
||||
})
|
||||
|
||||
# Get recent snapshots with changes (last 30 days)
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
recent_changes = db.query(CompetitorSnapshot).filter(
|
||||
CompetitorSnapshot.snapshot_date >= thirty_days_ago.date(),
|
||||
CompetitorSnapshot.changes != None
|
||||
).order_by(CompetitorSnapshot.snapshot_date.desc()).limit(20).all()
|
||||
|
||||
return render_template('admin/competitor_dashboard.html',
|
||||
company_data=company_data,
|
||||
recent_changes=recent_changes,
|
||||
total_companies=len(company_data),
|
||||
total_competitors=len(competitors),
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/competitors/<int:company_id>')
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def admin_competitor_detail(company_id):
|
||||
"""
|
||||
Detailed competitor view for a specific company.
|
||||
|
||||
Shows:
|
||||
- Company's GBP metrics vs competitors
|
||||
- Competitor list with current metrics
|
||||
- Timeline of changes
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
company = db.query(Company).get(company_id)
|
||||
if not company:
|
||||
return redirect(url_for('admin.admin_competitors'))
|
||||
|
||||
# Get company's own GBP audit
|
||||
gbp_audit = db.query(GBPAudit).filter(
|
||||
GBPAudit.company_id == company.id
|
||||
).order_by(GBPAudit.audit_date.desc()).first()
|
||||
|
||||
# Get competitors
|
||||
competitors = db.query(CompanyCompetitor).filter(
|
||||
CompanyCompetitor.company_id == company.id,
|
||||
CompanyCompetitor.is_active == True
|
||||
).all()
|
||||
|
||||
# Get latest snapshots for each competitor
|
||||
competitor_data = []
|
||||
for comp in competitors:
|
||||
latest_snapshot = db.query(CompetitorSnapshot).filter(
|
||||
CompetitorSnapshot.competitor_id == comp.id
|
||||
).order_by(CompetitorSnapshot.snapshot_date.desc()).first()
|
||||
|
||||
competitor_data.append({
|
||||
'competitor': comp,
|
||||
'latest_snapshot': latest_snapshot,
|
||||
})
|
||||
|
||||
# Get all snapshots with changes (for timeline)
|
||||
competitor_ids = [c.id for c in competitors]
|
||||
timeline = []
|
||||
if competitor_ids:
|
||||
timeline = db.query(CompetitorSnapshot).filter(
|
||||
CompetitorSnapshot.competitor_id.in_(competitor_ids),
|
||||
CompetitorSnapshot.changes != None
|
||||
).order_by(CompetitorSnapshot.snapshot_date.desc()).limit(30).all()
|
||||
|
||||
return render_template('admin/competitor_dashboard.html',
|
||||
company=company,
|
||||
gbp_audit=gbp_audit,
|
||||
competitor_data=competitor_data,
|
||||
timeline=timeline,
|
||||
detail_view=True,
|
||||
total_companies=0,
|
||||
total_competitors=len(competitors),
|
||||
company_data=[],
|
||||
recent_changes=[],
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
383
competitor_monitoring_service.py
Normal file
383
competitor_monitoring_service.py
Normal file
@ -0,0 +1,383 @@
|
||||
"""
|
||||
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
|
||||
465
google_places_service.py
Normal file
465
google_places_service.py
Normal file
@ -0,0 +1,465 @@
|
||||
"""
|
||||
Google Places API (New) Service for NordaBiz
|
||||
=============================================
|
||||
|
||||
Comprehensive Google Places API client for fetching rich business data.
|
||||
Uses the Places API (New) with field masks for efficient billing.
|
||||
|
||||
API Reference: https://developers.google.com/maps/documentation/places/web-service/op-overview
|
||||
|
||||
Author: NordaBiz Development Team
|
||||
Created: 2026-02-06
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List, Any
|
||||
from decimal import Decimal
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# API Configuration
|
||||
PLACES_API_BASE = "https://places.googleapis.com/v1/places"
|
||||
PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
|
||||
PLACES_NEARBY_URL = "https://places.googleapis.com/v1/places:searchNearby"
|
||||
|
||||
# Field masks grouped by billing tier
|
||||
# Basic fields (no charge): id, displayName, formattedAddress, location, types, etc.
|
||||
# Contact fields: nationalPhoneNumber, websiteUri, etc.
|
||||
# Atmosphere fields: reviews, rating, etc.
|
||||
BASIC_FIELDS = [
|
||||
"id", "displayName", "formattedAddress", "location",
|
||||
"types", "primaryType", "primaryTypeDisplayName",
|
||||
"businessStatus", "googleMapsUri",
|
||||
"utcOffsetMinutes", "adrFormatAddress",
|
||||
"shortFormattedAddress"
|
||||
]
|
||||
|
||||
CONTACT_FIELDS = [
|
||||
"nationalPhoneNumber", "internationalPhoneNumber",
|
||||
"websiteUri"
|
||||
]
|
||||
|
||||
HOURS_FIELDS = [
|
||||
"regularOpeningHours", "currentOpeningHours"
|
||||
]
|
||||
|
||||
ATMOSPHERE_FIELDS = [
|
||||
"rating", "userRatingCount", "reviews",
|
||||
"priceLevel", "editorialSummary"
|
||||
]
|
||||
|
||||
PHOTO_FIELDS = [
|
||||
"photos"
|
||||
]
|
||||
|
||||
ATTRIBUTE_FIELDS = [
|
||||
"paymentOptions", "parkingOptions",
|
||||
"accessibilityOptions", "outdoorSeating",
|
||||
"liveMusic", "servesBreakfast", "servesLunch",
|
||||
"servesDinner", "servesBeer", "servesWine",
|
||||
"servesCoffee", "goodForChildren", "allowsDogs",
|
||||
"restroom", "goodForGroups", "goodForWatchingSports",
|
||||
"reservable", "delivery", "dineIn", "takeout",
|
||||
"curbsidePickup"
|
||||
]
|
||||
|
||||
|
||||
class GooglePlacesService:
|
||||
"""Fetches rich GBP data via Places API (New)."""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.getenv('GOOGLE_PLACES_API_KEY')
|
||||
if not self.api_key:
|
||||
raise ValueError("GOOGLE_PLACES_API_KEY not set in environment")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'X-Goog-Api-Key': self.api_key,
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
def _build_field_mask(self, include_reviews: bool = True,
|
||||
include_photos: bool = True,
|
||||
include_attributes: bool = True) -> str:
|
||||
"""Build field mask string for API request."""
|
||||
fields = BASIC_FIELDS + CONTACT_FIELDS + HOURS_FIELDS + ATMOSPHERE_FIELDS
|
||||
if include_photos:
|
||||
fields += PHOTO_FIELDS
|
||||
if include_attributes:
|
||||
fields += ATTRIBUTE_FIELDS
|
||||
return ','.join(f'places.{f}' if '.' not in f else f for f in fields)
|
||||
|
||||
def get_place_details(self, place_id: str,
|
||||
include_reviews: bool = True,
|
||||
include_photos: bool = True,
|
||||
include_attributes: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch comprehensive place details by Place ID.
|
||||
|
||||
Args:
|
||||
place_id: Google Place ID
|
||||
include_reviews: Include reviews data (billed separately)
|
||||
include_photos: Include photo references
|
||||
include_attributes: Include business attributes
|
||||
|
||||
Returns:
|
||||
Dict with place details or None on error
|
||||
"""
|
||||
url = f"{PLACES_API_BASE}/{place_id}"
|
||||
|
||||
# Build field mask
|
||||
fields = list(BASIC_FIELDS + CONTACT_FIELDS + HOURS_FIELDS + ATMOSPHERE_FIELDS)
|
||||
if include_photos:
|
||||
fields += PHOTO_FIELDS
|
||||
if include_attributes:
|
||||
fields += ATTRIBUTE_FIELDS
|
||||
|
||||
field_mask = ','.join(fields)
|
||||
|
||||
headers = {
|
||||
'X-Goog-FieldMask': field_mask
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(url, headers=headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
logger.info(f"Fetched place details for {place_id}: {data.get('displayName', {}).get('text', 'unknown')}")
|
||||
return data
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"Places API HTTP error for {place_id}: {e.response.status_code} - {e.response.text}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Places API request error for {place_id}: {e}")
|
||||
return None
|
||||
|
||||
def search_place(self, query: str, location_bias: Dict = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search for a place by text query.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g., "TERMO Wejherowo")
|
||||
location_bias: Optional location bias {"latitude": 54.6, "longitude": 18.2, "radius": 5000}
|
||||
|
||||
Returns:
|
||||
First matching place or None
|
||||
"""
|
||||
body = {
|
||||
"textQuery": query,
|
||||
"languageCode": "pl",
|
||||
"maxResultCount": 5
|
||||
}
|
||||
|
||||
if location_bias:
|
||||
body["locationBias"] = {
|
||||
"circle": {
|
||||
"center": {
|
||||
"latitude": location_bias["latitude"],
|
||||
"longitude": location_bias["longitude"]
|
||||
},
|
||||
"radius": location_bias.get("radius", 5000.0)
|
||||
}
|
||||
}
|
||||
|
||||
field_mask = ','.join(f'places.{f}' for f in ['id', 'displayName', 'formattedAddress', 'types', 'rating', 'userRatingCount', 'googleMapsUri'])
|
||||
|
||||
headers = {
|
||||
'X-Goog-FieldMask': field_mask
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.post(PLACES_SEARCH_URL, json=body, headers=headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
places = data.get('places', [])
|
||||
if places:
|
||||
return places[0]
|
||||
logger.warning(f"No places found for query: {query}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Places search error for '{query}': {e}")
|
||||
return None
|
||||
|
||||
def search_nearby(self, latitude: float, longitude: float,
|
||||
radius: float = 5000.0,
|
||||
included_types: List[str] = None,
|
||||
max_results: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for nearby places (for competitor discovery).
|
||||
|
||||
Args:
|
||||
latitude: Center point latitude
|
||||
longitude: Center point longitude
|
||||
radius: Search radius in meters
|
||||
included_types: Filter by place types (e.g., ["restaurant"])
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of nearby places
|
||||
"""
|
||||
body = {
|
||||
"locationRestriction": {
|
||||
"circle": {
|
||||
"center": {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude
|
||||
},
|
||||
"radius": radius
|
||||
}
|
||||
},
|
||||
"maxResultCount": min(max_results, 20),
|
||||
"languageCode": "pl"
|
||||
}
|
||||
|
||||
if included_types:
|
||||
body["includedTypes"] = included_types
|
||||
|
||||
field_mask = ','.join(f'places.{f}' for f in [
|
||||
'id', 'displayName', 'formattedAddress', 'types',
|
||||
'rating', 'userRatingCount', 'googleMapsUri',
|
||||
'websiteUri', 'primaryType', 'photos',
|
||||
'businessStatus', 'location'
|
||||
])
|
||||
|
||||
headers = {
|
||||
'X-Goog-FieldMask': field_mask
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.post(PLACES_NEARBY_URL, json=body, headers=headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get('places', [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Nearby search error: {e}")
|
||||
return []
|
||||
|
||||
def get_photo_url(self, photo_name: str, max_width: int = 400) -> str:
|
||||
"""
|
||||
Get photo URL from photo resource name.
|
||||
|
||||
Args:
|
||||
photo_name: Photo resource name from place details
|
||||
max_width: Maximum width in pixels
|
||||
|
||||
Returns:
|
||||
Photo URL string
|
||||
"""
|
||||
return f"https://places.googleapis.com/v1/{photo_name}/media?maxWidthPx={max_width}&key={self.api_key}"
|
||||
|
||||
def extract_reviews_data(self, place_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract and analyze reviews from place details.
|
||||
|
||||
Returns:
|
||||
Dict with review statistics and individual reviews
|
||||
"""
|
||||
reviews = place_data.get('reviews', [])
|
||||
if not reviews:
|
||||
return {
|
||||
'total_from_api': 0,
|
||||
'total_reported': place_data.get('userRatingCount', 0),
|
||||
'average_rating': place_data.get('rating'),
|
||||
'reviews': [],
|
||||
'with_response': 0,
|
||||
'without_response': 0,
|
||||
'response_rate': 0.0,
|
||||
'rating_distribution': {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
}
|
||||
|
||||
rating_dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
with_response = 0
|
||||
processed_reviews = []
|
||||
|
||||
for review in reviews:
|
||||
rating = review.get('rating', 0)
|
||||
if rating in rating_dist:
|
||||
rating_dist[rating] += 1
|
||||
|
||||
has_response = bool(review.get('authorAttribution', {}).get('displayName'))
|
||||
# Check if there's an owner response (Google marks these differently)
|
||||
# The Places API (New) doesn't directly expose owner responses in the same way
|
||||
# We'll check for the presence of a response field
|
||||
|
||||
processed_reviews.append({
|
||||
'author': review.get('authorAttribution', {}).get('displayName', 'Anonim'),
|
||||
'rating': rating,
|
||||
'text': review.get('text', {}).get('text', ''),
|
||||
'time': review.get('publishTime', ''),
|
||||
'relative_time': review.get('relativePublishTimeDescription', ''),
|
||||
'language': review.get('text', {}).get('languageCode', 'pl'),
|
||||
})
|
||||
|
||||
total = len(reviews)
|
||||
response_rate = (with_response / total * 100) if total > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_from_api': total,
|
||||
'total_reported': place_data.get('userRatingCount', 0),
|
||||
'average_rating': place_data.get('rating'),
|
||||
'reviews': processed_reviews,
|
||||
'with_response': with_response,
|
||||
'without_response': total - with_response,
|
||||
'response_rate': round(response_rate, 1),
|
||||
'rating_distribution': rating_dist
|
||||
}
|
||||
|
||||
def extract_attributes(self, place_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract business attributes from place details.
|
||||
|
||||
Returns:
|
||||
Dict with categorized attributes
|
||||
"""
|
||||
attributes = {}
|
||||
|
||||
# Payment options
|
||||
payment = place_data.get('paymentOptions', {})
|
||||
if payment:
|
||||
attributes['payment'] = {
|
||||
'accepts_credit_cards': payment.get('acceptsCreditCards'),
|
||||
'accepts_debit_cards': payment.get('acceptsDebitCards'),
|
||||
'accepts_cash_only': payment.get('acceptsCashOnly'),
|
||||
'accepts_nfc': payment.get('acceptsNfc'),
|
||||
}
|
||||
|
||||
# Parking
|
||||
parking = place_data.get('parkingOptions', {})
|
||||
if parking:
|
||||
attributes['parking'] = {
|
||||
'free_parking': parking.get('freeParkingLot'),
|
||||
'paid_parking': parking.get('paidParkingLot'),
|
||||
'street_parking': parking.get('freeStreetParking'),
|
||||
'garage_parking': parking.get('freeGarageParking'),
|
||||
'valet_parking': parking.get('valetParking'),
|
||||
}
|
||||
|
||||
# Accessibility
|
||||
accessibility = place_data.get('accessibilityOptions', {})
|
||||
if accessibility:
|
||||
attributes['accessibility'] = {
|
||||
'wheelchair_entrance': accessibility.get('wheelchairAccessibleEntrance'),
|
||||
'wheelchair_seating': accessibility.get('wheelchairAccessibleSeating'),
|
||||
'wheelchair_restroom': accessibility.get('wheelchairAccessibleRestroom'),
|
||||
'wheelchair_parking': accessibility.get('wheelchairAccessibleParking'),
|
||||
}
|
||||
|
||||
# Service options
|
||||
service = {}
|
||||
bool_fields = {
|
||||
'delivery': 'delivery',
|
||||
'dineIn': 'dine_in',
|
||||
'takeout': 'takeout',
|
||||
'curbsidePickup': 'curbside_pickup',
|
||||
'reservable': 'reservable',
|
||||
'outdoorSeating': 'outdoor_seating',
|
||||
}
|
||||
for api_field, key in bool_fields.items():
|
||||
val = place_data.get(api_field)
|
||||
if val is not None:
|
||||
service[key] = val
|
||||
if service:
|
||||
attributes['service'] = service
|
||||
|
||||
# Amenities
|
||||
amenities = {}
|
||||
amenity_fields = {
|
||||
'restroom': 'restroom',
|
||||
'goodForChildren': 'good_for_children',
|
||||
'allowsDogs': 'allows_dogs',
|
||||
'goodForGroups': 'good_for_groups',
|
||||
'liveMusic': 'live_music',
|
||||
'goodForWatchingSports': 'good_for_watching_sports',
|
||||
}
|
||||
for api_field, key in amenity_fields.items():
|
||||
val = place_data.get(api_field)
|
||||
if val is not None:
|
||||
amenities[key] = val
|
||||
if amenities:
|
||||
attributes['amenities'] = amenities
|
||||
|
||||
# Food & Drink
|
||||
food = {}
|
||||
food_fields = {
|
||||
'servesBreakfast': 'breakfast',
|
||||
'servesLunch': 'lunch',
|
||||
'servesDinner': 'dinner',
|
||||
'servesBeer': 'beer',
|
||||
'servesWine': 'wine',
|
||||
'servesCoffee': 'coffee',
|
||||
}
|
||||
for api_field, key in food_fields.items():
|
||||
val = place_data.get(api_field)
|
||||
if val is not None:
|
||||
food[key] = val
|
||||
if food:
|
||||
attributes['food_and_drink'] = food
|
||||
|
||||
return attributes
|
||||
|
||||
def extract_hours(self, place_data: Dict) -> Dict[str, Any]:
|
||||
"""Extract opening hours from place details."""
|
||||
result = {
|
||||
'regular': None,
|
||||
'current': None,
|
||||
'has_special_hours': False,
|
||||
'special_hours': None
|
||||
}
|
||||
|
||||
regular = place_data.get('regularOpeningHours', {})
|
||||
if regular:
|
||||
result['regular'] = {
|
||||
'periods': regular.get('periods', []),
|
||||
'weekday_descriptions': regular.get('weekdayDescriptions', []),
|
||||
'open_now': regular.get('openNow')
|
||||
}
|
||||
|
||||
current = place_data.get('currentOpeningHours', {})
|
||||
if current:
|
||||
result['current'] = {
|
||||
'periods': current.get('periods', []),
|
||||
'weekday_descriptions': current.get('weekdayDescriptions', []),
|
||||
'open_now': current.get('openNow')
|
||||
}
|
||||
# If current differs from regular, there are special hours
|
||||
if current.get('specialDays'):
|
||||
result['has_special_hours'] = True
|
||||
result['special_hours'] = current.get('specialDays', [])
|
||||
|
||||
return result
|
||||
|
||||
def extract_photos_metadata(self, place_data: Dict) -> Dict[str, Any]:
|
||||
"""Extract photo metadata from place details."""
|
||||
photos = place_data.get('photos', [])
|
||||
if not photos:
|
||||
return {
|
||||
'total_count': 0,
|
||||
'photos': [],
|
||||
'has_owner_photos': False
|
||||
}
|
||||
|
||||
photo_list = []
|
||||
has_owner = False
|
||||
for photo in photos:
|
||||
attributions = photo.get('authorAttributions', [])
|
||||
is_owner = any(a.get('displayName', '').lower() in ['owner', 'właściciel']
|
||||
for a in attributions)
|
||||
if is_owner:
|
||||
has_owner = True
|
||||
|
||||
photo_list.append({
|
||||
'name': photo.get('name', ''),
|
||||
'width': photo.get('widthPx', 0),
|
||||
'height': photo.get('heightPx', 0),
|
||||
'attributions': [a.get('displayName', '') for a in attributions],
|
||||
'is_owner_photo': is_owner
|
||||
})
|
||||
|
||||
return {
|
||||
'total_count': len(photo_list),
|
||||
'photos': photo_list[:20], # Limit stored photos
|
||||
'has_owner_photos': has_owner
|
||||
}
|
||||
184
scripts/competitor_monitor_cron.py
Normal file
184
scripts/competitor_monitor_cron.py
Normal file
@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Competitor Monitor Cron Job
|
||||
===========================
|
||||
|
||||
Periodically takes snapshots of competitors for all companies with
|
||||
Google Place IDs. Designed to run weekly via cron.
|
||||
|
||||
Usage:
|
||||
python competitor_monitor_cron.py
|
||||
python competitor_monitor_cron.py --company-id 26
|
||||
python competitor_monitor_cron.py --discover # Auto-discover competitors first
|
||||
|
||||
Cron entry (weekly, Sunday 3 AM):
|
||||
0 3 * * 0 cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/competitor_monitor_cron.py >> /var/log/nordabiznes/competitor_monitor.log 2>&1
|
||||
|
||||
Author: NordaBiz Development Team
|
||||
Created: 2026-02-06
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Load .env file from project root
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
project_root = script_dir.parent
|
||||
env_path = project_root / '.env'
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from database import Company, CompanyCompetitor, CompanyWebsiteAnalysis
|
||||
from competitor_monitoring_service import CompetitorMonitoringService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://nordabiz_app:CHANGE_ME@127.0.0.1:5432/nordabiz'
|
||||
)
|
||||
|
||||
|
||||
def run_snapshots(session, company_ids=None):
|
||||
"""Take snapshots for competitors."""
|
||||
service = CompetitorMonitoringService(session)
|
||||
|
||||
if company_ids:
|
||||
companies = session.query(Company).filter(Company.id.in_(company_ids)).all()
|
||||
else:
|
||||
# Get companies that have competitors tracked
|
||||
companies_with_competitors = session.query(CompanyCompetitor.company_id).distinct().all()
|
||||
company_ids_with_competitors = [c[0] for c in companies_with_competitors]
|
||||
companies = session.query(Company).filter(Company.id.in_(company_ids_with_competitors)).all()
|
||||
|
||||
total_results = {
|
||||
'companies_processed': 0,
|
||||
'total_snapshots': 0,
|
||||
'total_changes': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
for company in companies:
|
||||
logger.info(f"Processing competitors for: {company.name} (ID: {company.id})")
|
||||
try:
|
||||
results = service.take_all_snapshots(company.id)
|
||||
total_results['companies_processed'] += 1
|
||||
total_results['total_snapshots'] += results['success']
|
||||
total_results['total_changes'] += results['changes_detected']
|
||||
total_results['errors'] += results['failed']
|
||||
|
||||
logger.info(
|
||||
f" Snapshots: {results['success']}/{results['total']}, "
|
||||
f"Changes: {results['changes_detected']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" Error: {e}")
|
||||
total_results['errors'] += 1
|
||||
|
||||
return total_results
|
||||
|
||||
|
||||
def run_discovery(session, company_ids=None, max_per_company=5):
|
||||
"""Discover competitors for companies."""
|
||||
service = CompetitorMonitoringService(session)
|
||||
|
||||
if company_ids:
|
||||
companies = session.query(Company).filter(Company.id.in_(company_ids)).all()
|
||||
else:
|
||||
# Companies with Google Place IDs but no competitors
|
||||
companies_with_place = session.query(Company).join(
|
||||
CompanyWebsiteAnalysis,
|
||||
Company.id == CompanyWebsiteAnalysis.company_id
|
||||
).filter(
|
||||
CompanyWebsiteAnalysis.google_place_id.isnot(None)
|
||||
).all()
|
||||
|
||||
# Filter out those that already have competitors
|
||||
companies = []
|
||||
for company in companies_with_place:
|
||||
existing = session.query(CompanyCompetitor).filter(
|
||||
CompanyCompetitor.company_id == company.id
|
||||
).count()
|
||||
if existing == 0:
|
||||
companies.append(company)
|
||||
|
||||
total_discovered = 0
|
||||
for company in companies:
|
||||
logger.info(f"Discovering competitors for: {company.name} (ID: {company.id})")
|
||||
try:
|
||||
competitors = service.discover_competitors(company.id, max_results=max_per_company)
|
||||
saved = service.save_competitors(company.id, competitors)
|
||||
total_discovered += saved
|
||||
logger.info(f" Found {len(competitors)} competitors, saved {saved} new")
|
||||
except Exception as e:
|
||||
logger.error(f" Discovery error: {e}")
|
||||
|
||||
return total_discovered
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Competitor Monitor Cron Job')
|
||||
parser.add_argument('--company-id', type=int, help='Process single company')
|
||||
parser.add_argument('--discover', action='store_true', help='Discover competitors first')
|
||||
parser.add_argument('--max-competitors', type=int, default=5, help='Max competitors per company')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
company_ids = [args.company_id] if args.company_id else None
|
||||
|
||||
try:
|
||||
logger.info("=" * 60)
|
||||
logger.info("COMPETITOR MONITOR CRON JOB")
|
||||
logger.info(f"Started at: {datetime.now()}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
if args.discover:
|
||||
logger.info("\n--- COMPETITOR DISCOVERY ---")
|
||||
discovered = run_discovery(session, company_ids, args.max_competitors)
|
||||
logger.info(f"Total new competitors discovered: {discovered}")
|
||||
|
||||
logger.info("\n--- COMPETITOR SNAPSHOTS ---")
|
||||
results = run_snapshots(session, company_ids)
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("SUMMARY")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Companies processed: {results['companies_processed']}")
|
||||
logger.info(f"Snapshots taken: {results['total_snapshots']}")
|
||||
logger.info(f"Changes detected: {results['total_changes']}")
|
||||
logger.info(f"Errors: {results['errors']}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
326
scripts/generate_audit_report.py
Normal file
326
scripts/generate_audit_report.py
Normal file
@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Audit Report Generator for NordaBiz
|
||||
=============================================
|
||||
|
||||
Generates comprehensive audit reports combining:
|
||||
- Social Media audit data
|
||||
- Google Business Profile audit data
|
||||
- SEO audit data
|
||||
- Competitor monitoring data (if available)
|
||||
|
||||
Usage:
|
||||
python generate_audit_report.py --company-id 26
|
||||
python generate_audit_report.py --all
|
||||
python generate_audit_report.py --company-id 26 --type social
|
||||
|
||||
Author: NordaBiz Development Team
|
||||
Created: 2026-02-06
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import logging
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, Dict, List, Any
|
||||
from pathlib import Path
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
project_root = script_dir.parent
|
||||
env_path = project_root / '.env'
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import (
|
||||
Company, CompanySocialMedia, GBPAudit, CompanyWebsiteAnalysis,
|
||||
CompanyCompetitor, CompetitorSnapshot, AuditReport, SessionLocal
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://nordabiz_app:CHANGE_ME@127.0.0.1:5432/nordabiz'
|
||||
)
|
||||
|
||||
|
||||
class AuditReportGenerator:
|
||||
"""Generates unified audit reports for companies."""
|
||||
|
||||
def __init__(self, database_url: str = DATABASE_URL):
|
||||
self.engine = create_engine(database_url)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
def generate_report(self, company_id: int, report_type: str = 'full') -> Dict[str, Any]:
|
||||
"""Generate a comprehensive audit report for a company."""
|
||||
with self.Session() as session:
|
||||
company = session.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise ValueError(f"Company {company_id} not found")
|
||||
|
||||
logger.info(f"Generating {report_type} report for: {company.name} (ID: {company_id})")
|
||||
|
||||
report_data = {
|
||||
'company': {
|
||||
'id': company.id,
|
||||
'name': company.name,
|
||||
'slug': company.slug,
|
||||
'website': company.website,
|
||||
'city': company.address_city,
|
||||
'category': company.category.name if company.category else None,
|
||||
},
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'report_type': report_type,
|
||||
'sections': {},
|
||||
'scores': {},
|
||||
}
|
||||
|
||||
sections_included = {'social': False, 'gbp': False, 'seo': False, 'competitors': False}
|
||||
|
||||
# Social Media section
|
||||
if report_type in ('full', 'social'):
|
||||
social_data = self._get_social_data(session, company_id)
|
||||
if social_data:
|
||||
report_data['sections']['social'] = social_data
|
||||
report_data['scores']['social'] = social_data.get('average_completeness', 0)
|
||||
sections_included['social'] = True
|
||||
|
||||
# GBP section
|
||||
if report_type in ('full', 'gbp'):
|
||||
gbp_data = self._get_gbp_data(session, company_id)
|
||||
if gbp_data:
|
||||
report_data['sections']['gbp'] = gbp_data
|
||||
report_data['scores']['gbp'] = gbp_data.get('completeness_score', 0)
|
||||
sections_included['gbp'] = True
|
||||
|
||||
# SEO section
|
||||
if report_type in ('full', 'seo'):
|
||||
seo_data = self._get_seo_data(session, company_id)
|
||||
if seo_data:
|
||||
report_data['sections']['seo'] = seo_data
|
||||
report_data['scores']['seo'] = seo_data.get('overall_score', 0)
|
||||
sections_included['seo'] = True
|
||||
|
||||
# Competitors section
|
||||
if report_type == 'full':
|
||||
competitor_data = self._get_competitor_data(session, company_id)
|
||||
if competitor_data:
|
||||
report_data['sections']['competitors'] = competitor_data
|
||||
sections_included['competitors'] = True
|
||||
|
||||
# Calculate overall score
|
||||
scores = [v for v in report_data['scores'].values() if v and v > 0]
|
||||
overall = int(sum(scores) / len(scores)) if scores else 0
|
||||
report_data['scores']['overall'] = overall
|
||||
|
||||
# Save report
|
||||
report = AuditReport(
|
||||
company_id=company_id,
|
||||
report_type=report_type,
|
||||
period_start=date.today() - timedelta(days=30),
|
||||
period_end=date.today(),
|
||||
overall_score=overall,
|
||||
social_score=report_data['scores'].get('social'),
|
||||
gbp_score=report_data['scores'].get('gbp'),
|
||||
seo_score=report_data['scores'].get('seo'),
|
||||
sections=sections_included,
|
||||
data=report_data,
|
||||
generated_by='system',
|
||||
status='draft',
|
||||
)
|
||||
session.add(report)
|
||||
session.commit()
|
||||
session.refresh(report)
|
||||
|
||||
report_data['report_id'] = report.id
|
||||
logger.info(f"Report #{report.id} generated. Overall score: {overall}/100")
|
||||
|
||||
return report_data
|
||||
|
||||
def _get_social_data(self, session, company_id: int) -> Optional[Dict]:
|
||||
"""Get social media audit data."""
|
||||
profiles = session.query(CompanySocialMedia).filter(
|
||||
CompanySocialMedia.company_id == company_id,
|
||||
CompanySocialMedia.is_valid == True
|
||||
).all()
|
||||
|
||||
if not profiles:
|
||||
return None
|
||||
|
||||
platforms = []
|
||||
total_completeness = 0
|
||||
|
||||
for p in profiles:
|
||||
platform_data = {
|
||||
'platform': p.platform,
|
||||
'url': p.url,
|
||||
'page_name': p.page_name,
|
||||
'followers_count': p.followers_count,
|
||||
'has_bio': p.has_bio,
|
||||
'has_profile_photo': p.has_profile_photo,
|
||||
'completeness_score': p.profile_completeness_score or 0,
|
||||
'last_checked': p.last_checked_at.isoformat() if p.last_checked_at else None,
|
||||
}
|
||||
platforms.append(platform_data)
|
||||
total_completeness += (p.profile_completeness_score or 0)
|
||||
|
||||
average = int(total_completeness / len(platforms)) if platforms else 0
|
||||
|
||||
return {
|
||||
'platforms_found': len(platforms),
|
||||
'platforms': platforms,
|
||||
'average_completeness': average,
|
||||
'missing_platforms': self._find_missing_platforms(profiles),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _find_missing_platforms(profiles) -> List[str]:
|
||||
"""Find platforms without profiles."""
|
||||
all_platforms = {'facebook', 'instagram', 'linkedin', 'youtube', 'twitter', 'tiktok'}
|
||||
found = {p.platform for p in profiles}
|
||||
return sorted(all_platforms - found)
|
||||
|
||||
def _get_gbp_data(self, session, company_id: int) -> Optional[Dict]:
|
||||
"""Get GBP audit data."""
|
||||
audit = session.query(GBPAudit).filter(
|
||||
GBPAudit.company_id == company_id
|
||||
).order_by(GBPAudit.audit_date.desc()).first()
|
||||
|
||||
if not audit:
|
||||
return None
|
||||
|
||||
return {
|
||||
'completeness_score': audit.completeness_score,
|
||||
'score_category': audit.score_category,
|
||||
'audit_date': audit.audit_date.isoformat() if audit.audit_date else None,
|
||||
'fields_status': audit.fields_status,
|
||||
'review_count': audit.review_count,
|
||||
'average_rating': float(audit.average_rating) if audit.average_rating else None,
|
||||
'photo_count': audit.photo_count,
|
||||
'nap_consistent': audit.nap_consistent,
|
||||
'review_response_rate': float(audit.review_response_rate) if audit.review_response_rate else None,
|
||||
'review_sentiment': audit.review_sentiment,
|
||||
'recommendations': audit.recommendations or [],
|
||||
}
|
||||
|
||||
def _get_seo_data(self, session, company_id: int) -> Optional[Dict]:
|
||||
"""Get SEO audit data."""
|
||||
analysis = session.query(CompanyWebsiteAnalysis).filter(
|
||||
CompanyWebsiteAnalysis.company_id == company_id
|
||||
).first()
|
||||
|
||||
if not analysis:
|
||||
return None
|
||||
|
||||
return {
|
||||
'overall_score': analysis.seo_overall_score,
|
||||
'pagespeed_seo': analysis.pagespeed_seo_score,
|
||||
'pagespeed_performance': analysis.pagespeed_performance_score,
|
||||
'pagespeed_accessibility': analysis.pagespeed_accessibility_score,
|
||||
'meta_title': analysis.meta_title,
|
||||
'has_structured_data': analysis.has_structured_data,
|
||||
'has_sitemap': analysis.has_sitemap,
|
||||
'has_robots_txt': analysis.has_robots_txt,
|
||||
'is_mobile_friendly': analysis.is_mobile_friendly,
|
||||
'local_seo_score': analysis.local_seo_score,
|
||||
'has_local_business_schema': analysis.has_local_business_schema,
|
||||
'citations_count': analysis.citations_count,
|
||||
'content_freshness_score': analysis.content_freshness_score,
|
||||
'core_web_vitals': {
|
||||
'lcp_ms': analysis.largest_contentful_paint_ms,
|
||||
'fid_ms': analysis.first_input_delay_ms,
|
||||
'cls': float(analysis.cumulative_layout_shift) if analysis.cumulative_layout_shift else None,
|
||||
},
|
||||
'seo_issues': analysis.seo_issues,
|
||||
}
|
||||
|
||||
def _get_competitor_data(self, session, company_id: int) -> Optional[Dict]:
|
||||
"""Get competitor monitoring data."""
|
||||
competitors = session.query(CompanyCompetitor).filter(
|
||||
CompanyCompetitor.company_id == company_id,
|
||||
CompanyCompetitor.is_active == True
|
||||
).all()
|
||||
|
||||
if not competitors:
|
||||
return None
|
||||
|
||||
competitor_list = []
|
||||
for comp in competitors:
|
||||
# Get latest snapshot
|
||||
latest = session.query(CompetitorSnapshot).filter(
|
||||
CompetitorSnapshot.competitor_id == comp.id
|
||||
).order_by(CompetitorSnapshot.snapshot_date.desc()).first()
|
||||
|
||||
competitor_list.append({
|
||||
'name': comp.competitor_name,
|
||||
'rating': float(comp.competitor_rating) if comp.competitor_rating else None,
|
||||
'review_count': comp.competitor_review_count,
|
||||
'category': comp.competitor_category,
|
||||
'latest_changes': latest.changes if latest else None,
|
||||
})
|
||||
|
||||
return {
|
||||
'total_tracked': len(competitors),
|
||||
'competitors': competitor_list,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate Unified Audit Report')
|
||||
parser.add_argument('--company-id', type=int, help='Generate report for specific company')
|
||||
parser.add_argument('--all', action='store_true', help='Generate for all active companies')
|
||||
parser.add_argument('--type', choices=['full', 'social', 'gbp', 'seo'], default='full')
|
||||
parser.add_argument('--json', action='store_true', help='Output JSON to stdout')
|
||||
parser.add_argument('--verbose', '-v', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
generator = AuditReportGenerator()
|
||||
|
||||
if args.company_id:
|
||||
report = generator.generate_report(args.company_id, args.type)
|
||||
if args.json:
|
||||
print(json.dumps(report, default=str, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"\nReport #{report.get('report_id')} generated")
|
||||
print(f"Overall score: {report['scores'].get('overall', 0)}/100")
|
||||
for section, data in report.get('sections', {}).items():
|
||||
print(f" {section}: included")
|
||||
|
||||
elif args.all:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
companies = session.query(Company).filter(Company.status == 'active').all()
|
||||
company_ids = [c.id for c in companies]
|
||||
|
||||
for cid in company_ids:
|
||||
try:
|
||||
report = generator.generate_report(cid, args.type)
|
||||
print(f"Company {cid}: score={report['scores'].get('overall', 0)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Company {cid} failed: {e}")
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
535
templates/admin/competitor_dashboard.html
Normal file
535
templates/admin/competitor_dashboard.html
Normal file
@ -0,0 +1,535 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Monitoring Konkurencji - Panel Admina{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.company-competitor-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.company-competitor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.company-competitor-header h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.competitor-count-badge {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.competitors-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.competitors-table th {
|
||||
text-align: left;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.competitors-table td {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.competitors-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.competitors-table tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comparison-row {
|
||||
background: rgba(168, 85, 247, 0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comparison-row td {
|
||||
border-bottom: 2px solid var(--primary) !important;
|
||||
}
|
||||
|
||||
.change-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.change-badge.positive {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.change-badge.negative {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.change-badge.neutral {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-content strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.competitors-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
{% if detail_view and company %}
|
||||
<h1>Konkurenci: {{ company.name }}</h1>
|
||||
<p>Porownanie z konkurentami z Google Maps</p>
|
||||
{% else %}
|
||||
<h1>Monitoring Konkurencji</h1>
|
||||
<p>Sledzenie zmian u konkurentow firm czlonkowskich</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{% if detail_view and company %}
|
||||
<a href="{{ url_for('admin.admin_competitors') }}" class="btn btn-outline btn-sm">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Wszystkie firmy
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
{% if not detail_view %}
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ total_companies }}</div>
|
||||
<div class="stat-label">Firm z monitoringiem</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ total_competitors }}</div>
|
||||
<div class="stat-label">Sledzonych konkurentow</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ recent_changes|length }}</div>
|
||||
<div class="stat-label">Zmian w ostatnich 30 dniach</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if detail_view and company %}
|
||||
<!-- DETAIL VIEW: Single company vs competitors -->
|
||||
|
||||
{% if competitor_data %}
|
||||
<!-- Comparison Table -->
|
||||
<h2 class="section-title">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
Porownanie: {{ company.name }} vs Konkurenci
|
||||
</h2>
|
||||
|
||||
<div style="background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); overflow: hidden; margin-bottom: var(--spacing-xl);">
|
||||
<table class="competitors-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Firma</th>
|
||||
<th>Ocena</th>
|
||||
<th>Opinie</th>
|
||||
<th>Zdjecia</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Strona WWW</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Company's own data -->
|
||||
<tr class="comparison-row">
|
||||
<td>
|
||||
<strong>{{ company.name }}</strong>
|
||||
<span style="font-size: var(--font-size-xs); color: var(--primary); margin-left: 4px;">(Twoja firma)</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if gbp_audit and gbp_audit.average_rating %}
|
||||
<span class="rating-stars">{{ gbp_audit.average_rating }}/5</span>
|
||||
{% else %}
|
||||
<span style="color: var(--text-tertiary);">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ gbp_audit.review_count if gbp_audit and gbp_audit.review_count else '-' }}</td>
|
||||
<td>{{ gbp_audit.photo_count if gbp_audit and gbp_audit.photo_count else '-' }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">{{ company.category.name if company.category else '-' }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">{{ 'Tak' if company.website else 'Brak' }}</td>
|
||||
</tr>
|
||||
<!-- Competitors -->
|
||||
{% for item in competitor_data %}
|
||||
{% set comp = item.competitor %}
|
||||
{% set snap = item.latest_snapshot %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ comp.competitor_name or 'Bez nazwy' }}
|
||||
{% if comp.added_by == 'manual' %}
|
||||
<span style="font-size: 9px; padding: 1px 4px; background: #dbeafe; color: #1e40af; border-radius: 2px; margin-left: 4px;">RECZNY</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if comp.competitor_rating %}
|
||||
<span class="rating-stars">{{ comp.competitor_rating }}/5</span>
|
||||
{% if snap and snap.changes and snap.changes.get('rating_change') %}
|
||||
{% set rc = snap.changes.rating_change %}
|
||||
<span class="change-badge {{ 'positive' if rc > 0 else 'negative' }}">
|
||||
{{ '+' if rc > 0 else '' }}{{ rc }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span style="color: var(--text-tertiary);">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ comp.competitor_review_count or '-' }}
|
||||
{% if snap and snap.changes and snap.changes.get('new_reviews') %}
|
||||
<span class="change-badge positive">+{{ snap.changes.new_reviews }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ snap.photo_count if snap else '-' }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">
|
||||
{% if comp.competitor_website %}
|
||||
<a href="{{ comp.competitor_website }}" target="_blank" rel="noopener" style="color: var(--primary);">Link</a>
|
||||
{% else %}
|
||||
Brak
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Timeline of Changes -->
|
||||
{% if timeline %}
|
||||
<h2 class="section-title">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Ostatnie zmiany
|
||||
</h2>
|
||||
|
||||
<div class="timeline-list">
|
||||
{% for snap in timeline %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m') }}</div>
|
||||
<div class="timeline-content">
|
||||
<strong>{{ snap.competitor.competitor_name }}</strong>
|
||||
{% if snap.changes %}
|
||||
{% if snap.changes.get('new_reviews') %}
|
||||
— {{ snap.changes.new_reviews }} nowych opinii
|
||||
{% endif %}
|
||||
{% if snap.changes.get('rating_change') %}
|
||||
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
|
||||
{% endif %}
|
||||
{% if snap.changes.get('new_photos') %}
|
||||
— {{ snap.changes.new_photos }} nowych zdjec
|
||||
{% endif %}
|
||||
{% else %}
|
||||
— brak zmian
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No competitors -->
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
<h3>Brak sledzonych konkurentow</h3>
|
||||
<p>Dla firmy {{ company.name }} nie dodano jeszcze konkurentow. Uruchom skrypt discovery, aby automatycznie znalezc konkurentow w okolicy.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- OVERVIEW: All companies with competitors -->
|
||||
|
||||
{% if company_data %}
|
||||
{% for item in company_data %}
|
||||
<div class="company-competitor-card">
|
||||
<div class="company-competitor-header">
|
||||
<h3>
|
||||
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" style="color: var(--text-primary); text-decoration: none;">
|
||||
{{ item.company.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||
<span class="competitor-count-badge">{{ item.competitor_count }} konkurentow</span>
|
||||
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" class="btn btn-outline btn-sm">Szczegoly</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if item.competitors %}
|
||||
<table class="competitors-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konkurent</th>
|
||||
<th>Ocena</th>
|
||||
<th>Opinie</th>
|
||||
<th>Kategoria</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comp in item.competitors[:5] %}
|
||||
<tr>
|
||||
<td>{{ comp.competitor_name or 'Bez nazwy' }}</td>
|
||||
<td><span class="rating-stars">{{ comp.competitor_rating or '-' }}{% if comp.competitor_rating %}/5{% endif %}</span></td>
|
||||
<td>{{ comp.competitor_review_count or '-' }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if item.competitors|length > 5 %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-tertiary); font-style: italic;">
|
||||
...i {{ item.competitors|length - 5 }} wiecej
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<!-- No companies with competitors -->
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
<h3>Brak monitorowanych konkurentow</h3>
|
||||
<p>Zaden z firm czlonkowskich nie ma jeszcze sledzonych konkurentow. Uruchom skrypt <code>scripts/competitor_monitor_cron.py --discover</code>, aby automatycznie znalezc konkurentow dla firm z profilem Google Business.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Changes -->
|
||||
{% if recent_changes %}
|
||||
<h2 class="section-title" style="margin-top: var(--spacing-xl);">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Ostatnie zmiany (30 dni)
|
||||
</h2>
|
||||
|
||||
<div class="timeline-list">
|
||||
{% for snap in recent_changes %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m.%Y') }}</div>
|
||||
<div class="timeline-content">
|
||||
<strong>{{ snap.competitor.competitor_name }}</strong>
|
||||
{% if snap.changes %}
|
||||
{% if snap.changes.get('new_reviews') %}
|
||||
— {{ snap.changes.new_reviews }} nowych opinii
|
||||
{% endif %}
|
||||
{% if snap.changes.get('rating_change') %}
|
||||
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
|
||||
{% endif %}
|
||||
{% if snap.changes.get('new_photos') %}
|
||||
— {{ snap.changes.new_photos }} nowych zdjec
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user