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
Previously search_place() blindly returned the first result, which could be a completely unrelated business. Now validates that at least one significant word from the company name appears in the Google result before accepting it. Prevents wrong GBP profiles being linked to companies (e.g. Rozsadni Bracia getting Zielony Zolwik's profile). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2107 lines
77 KiB
Python
2107 lines
77 KiB
Python
"""
|
|
GBP Audit Service for Norda Biznes Partner
|
|
=======================================
|
|
|
|
Google Business Profile completeness audit service with:
|
|
- Field-by-field completeness checking
|
|
- Weighted scoring algorithm
|
|
- AI-powered recommendations (via Gemini)
|
|
- Historical tracking
|
|
|
|
Inspired by Localo.com audit features.
|
|
|
|
Author: Norda Biznes Development Team
|
|
Created: 2026-01-08
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal, OAuthToken
|
|
import gemini_service
|
|
|
|
try:
|
|
from google_places_service import GooglePlacesService
|
|
except ImportError:
|
|
GooglePlacesService = None
|
|
|
|
# Configure logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Field weights for completeness scoring (total = 100)
|
|
FIELD_WEIGHTS = {
|
|
'name': 10, # Business name - essential
|
|
'address': 10, # Full address - essential for local SEO
|
|
'phone': 8, # Contact phone - important
|
|
'website': 8, # Business website - important
|
|
'hours': 8, # Opening hours - important for customers
|
|
'categories': 10, # Business categories - essential for discovery
|
|
'photos': 15, # Photos - high impact on engagement
|
|
'description': 12, # Business description - important for SEO
|
|
'services': 10, # Services list - important for discovery
|
|
'reviews': 9, # Review presence and rating - trust factor
|
|
}
|
|
|
|
# Photo requirements for optimal GBP profile
|
|
PHOTO_REQUIREMENTS = {
|
|
'minimum': 3, # Minimum photos for basic completeness
|
|
'recommended': 10, # Recommended for good profile
|
|
'optimal': 25, # Optimal for excellent profile
|
|
}
|
|
|
|
# Review thresholds
|
|
REVIEW_THRESHOLDS = {
|
|
'minimum': 1, # At least 1 review
|
|
'good': 5, # Good number of reviews
|
|
'excellent': 20, # Excellent review count
|
|
}
|
|
|
|
# Google Place Types to Polish translations
|
|
# Source: https://developers.google.com/maps/documentation/places/web-service/supported_types
|
|
GOOGLE_TYPES_PL = {
|
|
# Automotive
|
|
'car_dealer': 'Salon samochodowy',
|
|
'car_rental': 'Wynajem samochodów',
|
|
'car_repair': 'Warsztat samochodowy',
|
|
'car_wash': 'Myjnia samochodowa',
|
|
'electric_vehicle_charging_station': 'Stacja ładowania EV',
|
|
'gas_station': 'Stacja paliw',
|
|
'parking': 'Parking',
|
|
'rest_stop': 'Miejsce odpoczynku',
|
|
|
|
# Business
|
|
'farm': 'Gospodarstwo rolne',
|
|
|
|
# Culture
|
|
'art_gallery': 'Galeria sztuki',
|
|
'museum': 'Muzeum',
|
|
'performing_arts_theater': 'Teatr',
|
|
|
|
# Education
|
|
'library': 'Biblioteka',
|
|
'preschool': 'Przedszkole',
|
|
'primary_school': 'Szkoła podstawowa',
|
|
'school': 'Szkoła',
|
|
'secondary_school': 'Szkoła średnia',
|
|
'university': 'Uniwersytet',
|
|
|
|
# Entertainment and Recreation
|
|
'amusement_center': 'Centrum rozrywki',
|
|
'amusement_park': 'Park rozrywki',
|
|
'aquarium': 'Akwarium',
|
|
'banquet_hall': 'Sala bankietowa',
|
|
'bowling_alley': 'Kręgielnia',
|
|
'casino': 'Kasyno',
|
|
'community_center': 'Centrum społeczne',
|
|
'convention_center': 'Centrum konferencyjne',
|
|
'cultural_center': 'Centrum kultury',
|
|
'dog_park': 'Park dla psów',
|
|
'event_venue': 'Miejsce eventowe',
|
|
'hiking_area': 'Szlak turystyczny',
|
|
'historical_landmark': 'Zabytek',
|
|
'marina': 'Przystań',
|
|
'movie_rental': 'Wypożyczalnia filmów',
|
|
'movie_theater': 'Kino',
|
|
'national_park': 'Park narodowy',
|
|
'night_club': 'Klub nocny',
|
|
'park': 'Park',
|
|
'tourist_attraction': 'Atrakcja turystyczna',
|
|
'visitor_center': 'Centrum informacyjne',
|
|
'wedding_venue': 'Sala weselna',
|
|
'zoo': 'Zoo',
|
|
|
|
# Finance
|
|
'accounting': 'Biuro rachunkowe',
|
|
'atm': 'Bankomat',
|
|
'bank': 'Bank',
|
|
|
|
# Food and Drink
|
|
'american_restaurant': 'Restauracja amerykańska',
|
|
'bakery': 'Piekarnia',
|
|
'bar': 'Bar',
|
|
'barbecue_restaurant': 'Restauracja BBQ',
|
|
'brazilian_restaurant': 'Restauracja brazylijska',
|
|
'breakfast_restaurant': 'Restauracja śniadaniowa',
|
|
'brunch_restaurant': 'Restauracja brunchowa',
|
|
'cafe': 'Kawiarnia',
|
|
'chinese_restaurant': 'Restauracja chińska',
|
|
'coffee_shop': 'Kawiarnia',
|
|
'fast_food_restaurant': 'Fast food',
|
|
'french_restaurant': 'Restauracja francuska',
|
|
'greek_restaurant': 'Restauracja grecka',
|
|
'hamburger_restaurant': 'Burgery',
|
|
'ice_cream_shop': 'Lodziarnia',
|
|
'indian_restaurant': 'Restauracja indyjska',
|
|
'indonesian_restaurant': 'Restauracja indonezyjska',
|
|
'italian_restaurant': 'Restauracja włoska',
|
|
'japanese_restaurant': 'Restauracja japońska',
|
|
'korean_restaurant': 'Restauracja koreańska',
|
|
'lebanese_restaurant': 'Restauracja libańska',
|
|
'meal_delivery': 'Dostawa jedzenia',
|
|
'meal_takeaway': 'Jedzenie na wynos',
|
|
'mediterranean_restaurant': 'Restauracja śródziemnomorska',
|
|
'mexican_restaurant': 'Restauracja meksykańska',
|
|
'middle_eastern_restaurant': 'Restauracja bliskowschodnia',
|
|
'pizza_restaurant': 'Pizzeria',
|
|
'ramen_restaurant': 'Ramen',
|
|
'restaurant': 'Restauracja',
|
|
'sandwich_shop': 'Kanapki',
|
|
'seafood_restaurant': 'Owoce morza',
|
|
'spanish_restaurant': 'Restauracja hiszpańska',
|
|
'steak_house': 'Steakhouse',
|
|
'sushi_restaurant': 'Sushi',
|
|
'thai_restaurant': 'Restauracja tajska',
|
|
'turkish_restaurant': 'Restauracja turecka',
|
|
'vegan_restaurant': 'Restauracja wegańska',
|
|
'vegetarian_restaurant': 'Restauracja wegetariańska',
|
|
'vietnamese_restaurant': 'Restauracja wietnamska',
|
|
|
|
# Government
|
|
'city_hall': 'Ratusz',
|
|
'courthouse': 'Sąd',
|
|
'embassy': 'Ambasada',
|
|
'fire_station': 'Straż pożarna',
|
|
'local_government_office': 'Urząd',
|
|
'police': 'Policja',
|
|
'post_office': 'Poczta',
|
|
|
|
# Health and Wellness
|
|
'dental_clinic': 'Klinika stomatologiczna',
|
|
'dentist': 'Dentysta',
|
|
'doctor': 'Lekarz',
|
|
'drugstore': 'Apteka',
|
|
'hospital': 'Szpital',
|
|
'medical_lab': 'Laboratorium medyczne',
|
|
'pharmacy': 'Apteka',
|
|
'physiotherapist': 'Fizjoterapeuta',
|
|
'spa': 'Spa',
|
|
|
|
# Lodging
|
|
'bed_and_breakfast': 'Nocleg ze śniadaniem',
|
|
'campground': 'Pole namiotowe',
|
|
'camping_cabin': 'Domek kempingowy',
|
|
'cottage': 'Domek letniskowy',
|
|
'extended_stay_hotel': 'Hotel długoterminowy',
|
|
'farmstay': 'Agroturystyka',
|
|
'guest_house': 'Pensjonat',
|
|
'hostel': 'Hostel',
|
|
'hotel': 'Hotel',
|
|
'lodging': 'Nocleg',
|
|
'motel': 'Motel',
|
|
'private_guest_room': 'Pokój gościnny',
|
|
'resort_hotel': 'Hotel resortowy',
|
|
'rv_park': 'Park dla kamperów',
|
|
|
|
# Places of Worship
|
|
'church': 'Kościół',
|
|
'hindu_temple': 'Świątynia hinduska',
|
|
'mosque': 'Meczet',
|
|
'synagogue': 'Synagoga',
|
|
|
|
# Services
|
|
'barber_shop': 'Fryzjer męski',
|
|
'beauty_salon': 'Salon kosmetyczny',
|
|
'cemetery': 'Cmentarz',
|
|
'child_care_agency': 'Opieka nad dziećmi',
|
|
'consultant': 'Konsultant',
|
|
'courier_service': 'Usługi kurierskie',
|
|
'electrician': 'Elektryk',
|
|
'florist': 'Kwiaciarnia',
|
|
'funeral_home': 'Dom pogrzebowy',
|
|
'hair_care': 'Fryzjer',
|
|
'hair_salon': 'Salon fryzjerski',
|
|
'insurance_agency': 'Agencja ubezpieczeniowa',
|
|
'laundry': 'Pralnia',
|
|
'lawyer': 'Prawnik',
|
|
'locksmith': 'Ślusarz',
|
|
'moving_company': 'Firma przeprowadzkowa',
|
|
'painter': 'Malarz',
|
|
'plumber': 'Hydraulik',
|
|
'real_estate_agency': 'Agencja nieruchomości',
|
|
'roofing_contractor': 'Dekarz',
|
|
'storage': 'Magazyn',
|
|
'tailor': 'Krawiec',
|
|
'telecommunications_service_provider': 'Telekomunikacja',
|
|
'travel_agency': 'Biuro podróży',
|
|
'veterinary_care': 'Weterynarz',
|
|
|
|
# Shopping
|
|
'auto_parts_store': 'Sklep z częściami',
|
|
'bicycle_store': 'Sklep rowerowy',
|
|
'book_store': 'Księgarnia',
|
|
'cell_phone_store': 'Sklep z telefonami',
|
|
'clothing_store': 'Sklep odzieżowy',
|
|
'convenience_store': 'Sklep spożywczy',
|
|
'department_store': 'Dom towarowy',
|
|
'discount_store': 'Sklep z przecenami',
|
|
'electronics_store': 'Sklep elektroniczny',
|
|
'furniture_store': 'Sklep meblowy',
|
|
'gift_shop': 'Sklep z upominkami',
|
|
'grocery_store': 'Sklep spożywczy',
|
|
'hardware_store': 'Sklep z narzędziami',
|
|
'home_goods_store': 'Sklep z artykułami domowymi',
|
|
'home_improvement_store': 'Market budowlany',
|
|
'jewelry_store': 'Jubiler',
|
|
'liquor_store': 'Sklep monopolowy',
|
|
'market': 'Targ',
|
|
'pet_store': 'Sklep zoologiczny',
|
|
'shoe_store': 'Sklep obuwniczy',
|
|
'shopping_mall': 'Centrum handlowe',
|
|
'sporting_goods_store': 'Sklep sportowy',
|
|
'store': 'Sklep',
|
|
'supermarket': 'Supermarket',
|
|
'wholesaler': 'Hurtownia',
|
|
|
|
# Sports
|
|
'athletic_field': 'Boisko sportowe',
|
|
'fitness_center': 'Siłownia',
|
|
'golf_course': 'Pole golfowe',
|
|
'gym': 'Siłownia',
|
|
'playground': 'Plac zabaw',
|
|
'ski_resort': 'Ośrodek narciarski',
|
|
'sports_club': 'Klub sportowy',
|
|
'sports_complex': 'Kompleks sportowy',
|
|
'stadium': 'Stadion',
|
|
'swimming_pool': 'Basen',
|
|
|
|
# Transportation
|
|
'airport': 'Lotnisko',
|
|
'bus_station': 'Dworzec autobusowy',
|
|
'bus_stop': 'Przystanek autobusowy',
|
|
'ferry_terminal': 'Terminal promowy',
|
|
'heliport': 'Lądowisko helikopterów',
|
|
'light_rail_station': 'Stacja kolejki',
|
|
'park_and_ride': 'Park & Ride',
|
|
'subway_station': 'Stacja metra',
|
|
'taxi_stand': 'Postój taksówek',
|
|
'train_station': 'Dworzec kolejowy',
|
|
'transit_depot': 'Zajezdnia',
|
|
'transit_station': 'Stacja przesiadkowa',
|
|
'truck_stop': 'Parking dla TIR-ów',
|
|
|
|
# Generic types (often returned by Google)
|
|
'establishment': 'Firma',
|
|
'point_of_interest': 'Punkt zainteresowania',
|
|
'general_contractor': 'Firma budowlana',
|
|
'roofing_contractor': 'Dekarz',
|
|
'hvac_contractor': 'Klimatyzacja i ogrzewanie',
|
|
'plumber': 'Hydraulik',
|
|
'electrician': 'Elektryk',
|
|
'contractor': 'Wykonawca',
|
|
'construction_company': 'Firma budowlana',
|
|
'industrial_area': 'Strefa przemysłowa',
|
|
'office': 'Biuro',
|
|
'food': 'Gastronomia',
|
|
'health': 'Zdrowie',
|
|
'finance': 'Finanse',
|
|
'political': 'Instytucja polityczna',
|
|
'place_of_worship': 'Miejsce kultu',
|
|
'natural_feature': 'Obiekt przyrodniczy',
|
|
'locality': 'Miejscowość',
|
|
'sublocality': 'Dzielnica',
|
|
'neighborhood': 'Okolica',
|
|
'premise': 'Lokal',
|
|
'subpremise': 'Podlokal',
|
|
'route': 'Trasa',
|
|
'street_address': 'Adres',
|
|
'floor': 'Piętro',
|
|
'room': 'Pokój',
|
|
'postal_code': 'Kod pocztowy',
|
|
'country': 'Kraj',
|
|
'administrative_area_level_1': 'Województwo',
|
|
'administrative_area_level_2': 'Powiat',
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class FieldStatus:
|
|
"""Status of a single GBP field"""
|
|
field_name: str
|
|
status: str # 'complete', 'partial', 'missing'
|
|
value: Optional[Any] = None
|
|
score: float = 0.0
|
|
max_score: float = 0.0
|
|
recommendation: Optional[str] = None
|
|
details: Optional[Dict[str, Any]] = None # Additional field-specific details
|
|
|
|
|
|
@dataclass
|
|
class AuditResult:
|
|
"""Complete GBP audit result"""
|
|
company_id: int
|
|
completeness_score: int
|
|
fields: Dict[str, FieldStatus] = field(default_factory=dict)
|
|
recommendations: List[Dict[str, Any]] = field(default_factory=list)
|
|
photo_count: int = 0
|
|
logo_present: bool = False
|
|
cover_photo_present: bool = False
|
|
review_count: int = 0
|
|
average_rating: Optional[Decimal] = None
|
|
google_place_id: Optional[str] = None
|
|
google_maps_url: Optional[str] = None
|
|
audit_errors: Optional[str] = None
|
|
|
|
|
|
class GBPAuditService:
|
|
"""Service for auditing Google Business Profile completeness"""
|
|
|
|
def __init__(self, db: Session):
|
|
"""
|
|
Initialize GBP Audit service.
|
|
|
|
Args:
|
|
db: SQLAlchemy database session
|
|
"""
|
|
self.db = db
|
|
|
|
def audit_company(self, company_id: int) -> AuditResult:
|
|
"""
|
|
Run full GBP audit for a company.
|
|
|
|
Args:
|
|
company_id: ID of the company to audit
|
|
|
|
Returns:
|
|
AuditResult with completeness score and field details
|
|
"""
|
|
company = self.db.query(Company).filter(Company.id == company_id).first()
|
|
if not company:
|
|
raise ValueError(f"Company with id {company_id} not found")
|
|
|
|
# Get latest website analysis for Google Business data
|
|
website_analysis = self.db.query(CompanyWebsiteAnalysis).filter(
|
|
CompanyWebsiteAnalysis.company_id == company_id
|
|
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
|
|
|
|
# Audit each field
|
|
fields = {}
|
|
total_score = 0.0
|
|
recommendations = []
|
|
|
|
# Name check (uses Google data)
|
|
fields['name'] = self._check_name(company, website_analysis)
|
|
total_score += fields['name'].score
|
|
|
|
# Address check (uses Google data)
|
|
fields['address'] = self._check_address(company, website_analysis)
|
|
total_score += fields['address'].score
|
|
|
|
# Phone check (uses Google data)
|
|
fields['phone'] = self._check_phone(company, website_analysis)
|
|
total_score += fields['phone'].score
|
|
|
|
# Website check (uses Google data)
|
|
fields['website'] = self._check_website(company, website_analysis)
|
|
total_score += fields['website'].score
|
|
|
|
# Hours check (uses Google data)
|
|
fields['hours'] = self._check_hours(company, website_analysis)
|
|
total_score += fields['hours'].score
|
|
|
|
# Categories check (uses Google data - google_types)
|
|
fields['categories'] = self._check_categories(company, website_analysis)
|
|
total_score += fields['categories'].score
|
|
|
|
# Photos check (uses Google data)
|
|
fields['photos'] = self._check_photos(company, website_analysis)
|
|
total_score += fields['photos'].score
|
|
|
|
# Description check - Google API doesn't provide owner description
|
|
fields['description'] = self._check_description(company)
|
|
total_score += fields['description'].score
|
|
|
|
# Services check
|
|
fields['services'] = self._check_services(company)
|
|
total_score += fields['services'].score
|
|
|
|
# Reviews check (from website analysis)
|
|
fields['reviews'] = self._check_reviews(company, website_analysis)
|
|
total_score += fields['reviews'].score
|
|
|
|
# Build recommendations from fields with issues
|
|
for field_name, field_status in fields.items():
|
|
if field_status.recommendation:
|
|
priority = self._get_priority(field_status)
|
|
recommendations.append({
|
|
'priority': priority,
|
|
'field': field_name,
|
|
'recommendation': field_status.recommendation,
|
|
'impact': FIELD_WEIGHTS.get(field_name, 0)
|
|
})
|
|
|
|
# Sort recommendations by priority and impact
|
|
priority_order = {'high': 0, 'medium': 1, 'low': 2}
|
|
recommendations.sort(key=lambda x: (priority_order.get(x['priority'], 3), -x['impact']))
|
|
|
|
# Extract Google Business data from website analysis
|
|
google_place_id = None
|
|
google_maps_url = None
|
|
review_count = 0
|
|
average_rating = None
|
|
|
|
if website_analysis:
|
|
google_place_id = website_analysis.google_place_id
|
|
google_maps_url = website_analysis.google_maps_url
|
|
review_count = website_analysis.google_reviews_count or 0
|
|
average_rating = website_analysis.google_rating
|
|
|
|
# Create result
|
|
result = AuditResult(
|
|
company_id=company_id,
|
|
completeness_score=round(total_score),
|
|
fields=fields,
|
|
recommendations=recommendations,
|
|
photo_count=fields['photos'].value if isinstance(fields['photos'].value, int) else 0,
|
|
logo_present=(fields['photos'].value or 0) >= 1 if isinstance(fields['photos'].value, int) else False,
|
|
cover_photo_present=(fields['photos'].value or 0) >= 2 if isinstance(fields['photos'].value, int) else False,
|
|
review_count=review_count,
|
|
average_rating=average_rating,
|
|
google_place_id=google_place_id,
|
|
google_maps_url=google_maps_url
|
|
)
|
|
|
|
return result
|
|
|
|
def save_audit(self, result: AuditResult, source: str = 'manual') -> GBPAudit:
|
|
"""
|
|
Save audit result to database.
|
|
|
|
Args:
|
|
result: AuditResult to save
|
|
source: Audit source ('manual', 'automated', 'api')
|
|
|
|
Returns:
|
|
Saved GBPAudit record
|
|
"""
|
|
# Convert fields to JSON-serializable format
|
|
fields_status = {}
|
|
for name, field_status in result.fields.items():
|
|
# Keep dict values as-is for JSON serialization (e.g., opening hours)
|
|
# Convert other complex types to string
|
|
if field_status.value is None:
|
|
serialized_value = None
|
|
elif isinstance(field_status.value, (dict, list)):
|
|
serialized_value = field_status.value # Keep as dict/list for JSON
|
|
elif isinstance(field_status.value, (int, float, bool)):
|
|
serialized_value = field_status.value # Keep primitives as-is
|
|
else:
|
|
serialized_value = str(field_status.value)
|
|
|
|
fields_status[name] = {
|
|
'status': field_status.status,
|
|
'value': serialized_value,
|
|
'score': field_status.score,
|
|
'max_score': field_status.max_score,
|
|
'details': field_status.details
|
|
}
|
|
|
|
# Create audit record
|
|
audit = GBPAudit(
|
|
company_id=result.company_id,
|
|
audit_date=datetime.now(),
|
|
completeness_score=result.completeness_score,
|
|
fields_status=fields_status,
|
|
recommendations=result.recommendations,
|
|
has_name=result.fields.get('name', FieldStatus('name', 'missing')).status == 'complete',
|
|
has_address=result.fields.get('address', FieldStatus('address', 'missing')).status == 'complete',
|
|
has_phone=result.fields.get('phone', FieldStatus('phone', 'missing')).status == 'complete',
|
|
has_website=result.fields.get('website', FieldStatus('website', 'missing')).status == 'complete',
|
|
has_hours=result.fields.get('hours', FieldStatus('hours', 'missing')).status == 'complete',
|
|
has_categories=result.fields.get('categories', FieldStatus('categories', 'missing')).status == 'complete',
|
|
has_photos=result.photo_count > 0,
|
|
has_description=result.fields.get('description', FieldStatus('description', 'missing')).status == 'complete',
|
|
has_services=result.fields.get('services', FieldStatus('services', 'missing')).status == 'complete',
|
|
has_reviews=result.fields.get('reviews', FieldStatus('reviews', 'missing')).status in ['complete', 'partial'],
|
|
photo_count=result.photo_count,
|
|
logo_present=result.logo_present,
|
|
cover_photo_present=result.cover_photo_present,
|
|
review_count=result.review_count,
|
|
average_rating=result.average_rating,
|
|
google_place_id=result.google_place_id,
|
|
google_maps_url=result.google_maps_url,
|
|
audit_source=source,
|
|
audit_version='1.0',
|
|
audit_errors=result.audit_errors
|
|
)
|
|
|
|
self.db.add(audit)
|
|
self.db.commit()
|
|
self.db.refresh(audit)
|
|
|
|
logger.info(f"GBP audit saved for company {result.company_id}: score={result.completeness_score}")
|
|
return audit
|
|
|
|
def get_latest_audit(self, company_id: int) -> Optional[GBPAudit]:
|
|
"""
|
|
Get the most recent audit for a company.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
|
|
Returns:
|
|
Latest GBPAudit or None
|
|
"""
|
|
return self.db.query(GBPAudit).filter(
|
|
GBPAudit.company_id == company_id
|
|
).order_by(GBPAudit.audit_date.desc()).first()
|
|
|
|
def get_audit_history(self, company_id: int, limit: int = 10) -> List[GBPAudit]:
|
|
"""
|
|
Get audit history for a company.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
limit: Maximum number of audits to return
|
|
|
|
Returns:
|
|
List of GBPAudit records ordered by date descending
|
|
"""
|
|
return self.db.query(GBPAudit).filter(
|
|
GBPAudit.company_id == company_id
|
|
).order_by(GBPAudit.audit_date.desc()).limit(limit).all()
|
|
|
|
# === Field Check Methods ===
|
|
|
|
def _check_name(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check business name completeness - uses Google data"""
|
|
max_score = FIELD_WEIGHTS['name']
|
|
|
|
# Use Google name if available, fallback to NordaBiz
|
|
name = analysis.google_name if analysis and analysis.google_name else company.name
|
|
|
|
if name and len(name.strip()) >= 3:
|
|
return FieldStatus(
|
|
field_name='name',
|
|
status='complete',
|
|
value=name,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='name',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj nazwę firmy do wizytówki Google. Nazwa powinna być oficjalną nazwą firmy.'
|
|
)
|
|
|
|
def _check_address(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check address completeness - uses Google data"""
|
|
max_score = FIELD_WEIGHTS['address']
|
|
|
|
# Use Google address if available
|
|
google_address = analysis.google_address if analysis else None
|
|
|
|
if google_address and len(google_address.strip()) >= 10:
|
|
return FieldStatus(
|
|
field_name='address',
|
|
status='complete',
|
|
value=google_address,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
# Fallback to NordaBiz data
|
|
has_street = bool(company.address_street)
|
|
has_city = bool(company.address_city)
|
|
|
|
if has_city or has_street:
|
|
partial_score = max_score * 0.5
|
|
return FieldStatus(
|
|
field_name='address',
|
|
status='partial',
|
|
value=company.address_city or company.address_street,
|
|
score=partial_score,
|
|
max_score=max_score,
|
|
recommendation='Uzupełnij pełny adres firmy w Google Business Profile.'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='address',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj adres firmy do wizytówki Google. Pełny adres jest kluczowy dla lokalnego SEO.'
|
|
)
|
|
|
|
def _check_phone(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check phone number presence - uses Google data"""
|
|
max_score = FIELD_WEIGHTS['phone']
|
|
|
|
# Use Google phone if available
|
|
phone = analysis.google_phone if analysis and analysis.google_phone else company.phone
|
|
|
|
if phone and len(phone.strip()) >= 9:
|
|
return FieldStatus(
|
|
field_name='phone',
|
|
status='complete',
|
|
value=phone,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='phone',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj numer telefonu do wizytówki Google. Klienci oczekują możliwości bezpośredniego kontaktu.'
|
|
)
|
|
|
|
def _check_website(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check website presence - uses Google data"""
|
|
max_score = FIELD_WEIGHTS['website']
|
|
|
|
# Use Google website if available
|
|
website = analysis.google_website if analysis and analysis.google_website else company.website
|
|
|
|
if website and website.strip().startswith(('http://', 'https://')):
|
|
return FieldStatus(
|
|
field_name='website',
|
|
status='complete',
|
|
value=website,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
if website:
|
|
# Has website but might not be properly formatted
|
|
return FieldStatus(
|
|
field_name='website',
|
|
status='partial',
|
|
value=website,
|
|
score=max_score * 0.7,
|
|
max_score=max_score,
|
|
recommendation='Upewnij się, że adres strony w Google Business Profile zawiera protokół (https://).'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='website',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj stronę internetową do wizytówki Google. Link do strony zwiększa wiarygodność i ruch.'
|
|
)
|
|
|
|
def _check_hours(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check opening hours presence"""
|
|
max_score = FIELD_WEIGHTS['hours']
|
|
|
|
# Check if we have opening hours from Google Business Profile
|
|
if analysis and analysis.google_opening_hours:
|
|
return FieldStatus(
|
|
field_name='hours',
|
|
status='complete',
|
|
value=analysis.google_opening_hours,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='hours',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj godziny otwarcia firmy. Klienci chcą wiedzieć, kiedy mogą Cię odwiedzić.'
|
|
)
|
|
|
|
def _check_categories(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check business category completeness - uses Google data"""
|
|
max_score = FIELD_WEIGHTS['categories']
|
|
|
|
# Use Google types if available
|
|
google_types = analysis.google_types if analysis and analysis.google_types else None
|
|
|
|
if google_types and len(google_types) > 0:
|
|
# Translate Google types to Polish using GOOGLE_TYPES_PL dictionary
|
|
# Fallback to formatted English if translation not available
|
|
formatted_types = []
|
|
for t in google_types[:3]:
|
|
if t in GOOGLE_TYPES_PL:
|
|
formatted_types.append(GOOGLE_TYPES_PL[t])
|
|
else:
|
|
# Fallback: remove underscores, title case
|
|
formatted_types.append(t.replace('_', ' ').title())
|
|
|
|
return FieldStatus(
|
|
field_name='categories',
|
|
status='complete',
|
|
value=', '.join(formatted_types),
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
# Fallback to NordaBiz category
|
|
if company.category_id and company.category:
|
|
return FieldStatus(
|
|
field_name='categories',
|
|
status='partial',
|
|
value=company.category.name if company.category else None,
|
|
score=max_score * 0.5,
|
|
max_score=max_score,
|
|
recommendation='Dodaj kategorie w Google Business Profile. Kategorie z Google są ważniejsze dla SEO niż dane lokalne.'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='categories',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Wybierz główną kategorię działalności w Google Business Profile. Kategoria pomaga klientom znaleźć Twoją firmę.'
|
|
)
|
|
|
|
def _check_photos(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""Check photo completeness"""
|
|
max_score = FIELD_WEIGHTS['photos']
|
|
|
|
# Get Google Business Profile photo count from website analysis
|
|
photo_count = 0
|
|
if analysis and analysis.google_photos_count:
|
|
photo_count = analysis.google_photos_count
|
|
|
|
if photo_count >= PHOTO_REQUIREMENTS['recommended']:
|
|
return FieldStatus(
|
|
field_name='photos',
|
|
status='complete',
|
|
value=photo_count,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
if photo_count >= PHOTO_REQUIREMENTS['minimum']:
|
|
partial_score = max_score * (photo_count / PHOTO_REQUIREMENTS['recommended'])
|
|
return FieldStatus(
|
|
field_name='photos',
|
|
status='partial',
|
|
value=photo_count,
|
|
score=min(partial_score, max_score * 0.7),
|
|
max_score=max_score,
|
|
recommendation=f'Dodaj więcej zdjęć firmy. Zalecane minimum to {PHOTO_REQUIREMENTS["recommended"]} zdjęć.'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='photos',
|
|
status='missing',
|
|
value=photo_count,
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj zdjęcia firmy (logo, wnętrze, zespół, produkty). Wizytówki ze zdjęciami mają 42% więcej zapytań o wskazówki dojazdu.'
|
|
)
|
|
|
|
def _check_description(self, company: Company) -> FieldStatus:
|
|
"""Check business description completeness"""
|
|
max_score = FIELD_WEIGHTS['description']
|
|
|
|
# Check short and full descriptions
|
|
desc = company.description_full or company.description_short
|
|
|
|
if desc and len(desc.strip()) >= 100:
|
|
return FieldStatus(
|
|
field_name='description',
|
|
status='complete',
|
|
value=desc[:100] + '...' if len(desc) > 100 else desc,
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
if desc and len(desc.strip()) >= 30:
|
|
return FieldStatus(
|
|
field_name='description',
|
|
status='partial',
|
|
value=desc,
|
|
score=max_score * 0.5,
|
|
max_score=max_score,
|
|
recommendation='Rozbuduj opis firmy. Dobry opis powinien mieć minimum 100-200 znaków i zawierać słowa kluczowe.'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='description',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj szczegółowy opis firmy. Opisz czym się zajmujesz, jakie usługi oferujesz i co Cię wyróżnia.'
|
|
)
|
|
|
|
def _check_services(self, company: Company) -> FieldStatus:
|
|
"""Check services list completeness"""
|
|
max_score = FIELD_WEIGHTS['services']
|
|
|
|
# Check company services relationship
|
|
service_count = 0
|
|
if hasattr(company, 'services') and company.services:
|
|
service_count = len(company.services)
|
|
|
|
# Also check services_offered text field
|
|
has_services_text = bool(company.services_offered and len(company.services_offered.strip()) > 10)
|
|
|
|
if service_count >= 3 or has_services_text:
|
|
return FieldStatus(
|
|
field_name='services',
|
|
status='complete',
|
|
value=service_count if service_count else 'W opisie',
|
|
score=max_score,
|
|
max_score=max_score
|
|
)
|
|
|
|
if service_count >= 1:
|
|
return FieldStatus(
|
|
field_name='services',
|
|
status='partial',
|
|
value=service_count,
|
|
score=max_score * 0.5,
|
|
max_score=max_score,
|
|
recommendation='Dodaj więcej usług do wizytówki. Zalecane jest minimum 3-5 głównych usług.'
|
|
)
|
|
|
|
return FieldStatus(
|
|
field_name='services',
|
|
status='missing',
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Dodaj listę usług lub produktów. Pomaga to klientom zrozumieć Twoją ofertę.'
|
|
)
|
|
|
|
def _check_reviews(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus:
|
|
"""
|
|
Check reviews presence and quality.
|
|
|
|
Scoring logic (max 9 points):
|
|
=============================
|
|
COMPLETE (9/9 pts):
|
|
- 5+ opinii AND ocena >= 4.0
|
|
|
|
PARTIAL (3-8/9 pts):
|
|
- Bazowo: 3 pkt za pierwszą opinię
|
|
- +1 pkt za każdą kolejną opinię (do 4 opinii = 6 pkt max)
|
|
- +1 pkt bonus jeśli ocena >= 4.0 (do 7 pkt max)
|
|
|
|
Przykłady:
|
|
- 1 opinia, ocena 3.5 → 3/9 pkt
|
|
- 1 opinia, ocena 5.0 → 4/9 pkt (3 + 1 bonus)
|
|
- 3 opinie, ocena 4.2 → 6/9 pkt (3 + 2 + 1 bonus)
|
|
- 4 opinie, ocena 4.5 → 7/9 pkt (3 + 3 + 1 bonus)
|
|
|
|
MISSING (0/9 pts):
|
|
- Brak opinii
|
|
"""
|
|
max_score = FIELD_WEIGHTS['reviews']
|
|
|
|
review_count = 0
|
|
rating = None
|
|
|
|
if analysis:
|
|
review_count = analysis.google_reviews_count or 0
|
|
rating = analysis.google_rating
|
|
|
|
# COMPLETE: 5+ reviews with good rating (4.0+) → full 9/9 points
|
|
if review_count >= REVIEW_THRESHOLDS['good'] and rating and float(rating) >= 4.0:
|
|
return FieldStatus(
|
|
field_name='reviews',
|
|
status='complete',
|
|
value=f'{review_count} opinii, ocena {rating}',
|
|
score=max_score,
|
|
max_score=max_score,
|
|
details={
|
|
'review_count': review_count,
|
|
'rating': float(rating),
|
|
'base_score': 3,
|
|
'additional_score': min(review_count - 1, 4),
|
|
'rating_bonus': 1,
|
|
'breakdown': f'3 pkt (1. opinia) + {min(review_count - 1, 4)} pkt (kolejne) + 1 pkt (ocena) = {max_score}/{max_score}'
|
|
}
|
|
)
|
|
|
|
# PARTIAL: 1-4 reviews → proportional scoring
|
|
if review_count >= REVIEW_THRESHOLDS['minimum']:
|
|
# Base: 3 pts for first review + 1 pt per additional review (max 6 pts for 4 reviews)
|
|
base_score = 3
|
|
additional_score = min(review_count - 1, 4)
|
|
partial_score = base_score + additional_score
|
|
|
|
# Bonus: +1 pt for good rating (>= 4.0)
|
|
rating_bonus = 0
|
|
if rating and float(rating) >= 4.0:
|
|
rating_bonus = 1
|
|
partial_score = min(partial_score + rating_bonus, max_score - 2) # max 7 pts
|
|
|
|
# Build breakdown string
|
|
breakdown_parts = [f'3 pkt (1. opinia)']
|
|
if additional_score > 0:
|
|
breakdown_parts.append(f'{additional_score} pkt (kolejne)')
|
|
if rating_bonus > 0:
|
|
breakdown_parts.append(f'1 pkt (ocena)')
|
|
breakdown = ' + '.join(breakdown_parts) + f' = {partial_score}/{max_score}'
|
|
|
|
return FieldStatus(
|
|
field_name='reviews',
|
|
status='partial',
|
|
value=f'{review_count} opinii' + (f', ocena {rating}' if rating else ''),
|
|
score=partial_score,
|
|
max_score=max_score,
|
|
recommendation='Zachęcaj klientów do zostawiania opinii. Więcej pozytywnych recenzji zwiększa zaufanie.',
|
|
details={
|
|
'review_count': review_count,
|
|
'rating': float(rating) if rating else None,
|
|
'base_score': base_score,
|
|
'additional_score': additional_score,
|
|
'rating_bonus': rating_bonus,
|
|
'breakdown': breakdown
|
|
}
|
|
)
|
|
|
|
# MISSING: no reviews → 0/9 points
|
|
return FieldStatus(
|
|
field_name='reviews',
|
|
status='missing',
|
|
value=review_count,
|
|
score=0,
|
|
max_score=max_score,
|
|
recommendation='Zbieraj opinie od klientów. Wizytówki z opiniami są bardziej wiarygodne i lepiej widoczne.',
|
|
details={
|
|
'review_count': 0,
|
|
'rating': None,
|
|
'breakdown': '0 pkt (brak opinii)'
|
|
}
|
|
)
|
|
|
|
def _get_priority(self, field_status: FieldStatus) -> str:
|
|
"""Determine recommendation priority based on field importance and status"""
|
|
weight = FIELD_WEIGHTS.get(field_status.field_name, 0)
|
|
|
|
if field_status.status == 'missing':
|
|
if weight >= 10:
|
|
return 'high'
|
|
elif weight >= 8:
|
|
return 'medium'
|
|
else:
|
|
return 'low'
|
|
elif field_status.status == 'partial':
|
|
if weight >= 10:
|
|
return 'medium'
|
|
else:
|
|
return 'low'
|
|
|
|
return 'low'
|
|
|
|
# === Enhanced Analysis Methods ===
|
|
|
|
def analyze_reviews(self, company_id: int, place_data: Dict = None) -> Dict[str, Any]:
|
|
"""
|
|
Analyze reviews for a company using Google Places data.
|
|
|
|
Returns dict with:
|
|
- reviews_with_response, reviews_without_response
|
|
- review_response_rate
|
|
- review_sentiment (positive/neutral/negative counts)
|
|
- review_keywords (top words from reviews)
|
|
- reviews_30d (recent review count)
|
|
"""
|
|
result = {
|
|
'reviews_with_response': 0,
|
|
'reviews_without_response': 0,
|
|
'review_response_rate': 0.0,
|
|
'avg_review_response_days': None,
|
|
'review_sentiment': {'positive': 0, 'neutral': 0, 'negative': 0},
|
|
'reviews_30d': 0,
|
|
'review_keywords': [],
|
|
}
|
|
|
|
if not place_data or 'reviews' not in place_data:
|
|
return result
|
|
|
|
reviews = place_data.get('reviews', [])
|
|
if not reviews:
|
|
return result
|
|
|
|
# Analyze each review
|
|
keywords_count = {}
|
|
for review in reviews:
|
|
rating = review.get('rating', 0)
|
|
|
|
# Sentiment based on rating
|
|
if rating >= 4:
|
|
result['review_sentiment']['positive'] += 1
|
|
elif rating == 3:
|
|
result['review_sentiment']['neutral'] += 1
|
|
else:
|
|
result['review_sentiment']['negative'] += 1
|
|
|
|
# Extract keywords from review text
|
|
text = review.get('text', {})
|
|
review_text = text.get('text', '') if isinstance(text, dict) else str(text)
|
|
if review_text:
|
|
# Simple keyword extraction - split and count common words
|
|
words = review_text.lower().split()
|
|
stop_words = {'i', 'w', 'na', 'do', 'z', 'się', 'jest', 'nie', 'to', 'że',
|
|
'o', 'jak', 'za', 'od', 'po', 'ale', 'co', 'tak', 'a', 'te',
|
|
'ze', 'dla', 'są', 'ten', 'ta', 'już', 'czy', 'tego', 'tej'}
|
|
for word in words:
|
|
word = word.strip('.,!?;:"()[]')
|
|
if len(word) >= 4 and word not in stop_words:
|
|
keywords_count[word] = keywords_count.get(word, 0) + 1
|
|
|
|
# Top 10 keywords
|
|
sorted_keywords = sorted(keywords_count.items(), key=lambda x: x[1], reverse=True)
|
|
result['review_keywords'] = [k for k, v in sorted_keywords[:10]]
|
|
|
|
total = len(reviews)
|
|
# BUG FIX: Check ownerResponse (not authorAttribution.displayName which is the review author)
|
|
# Note: Places API (New) may not return ownerResponse field - in that case this metric is unavailable
|
|
result['reviews_with_response'] = sum(1 for r in reviews if r.get('ownerResponse'))
|
|
result['reviews_without_response'] = total - result['reviews_with_response']
|
|
result['review_response_rate'] = round(result['reviews_with_response'] / total * 100, 1) if total > 0 else 0.0
|
|
|
|
return result
|
|
|
|
def analyze_review_sentiment_ai(self, reviews_data: list) -> dict:
|
|
"""Analyze review sentiment using Gemini AI.
|
|
|
|
Args:
|
|
reviews_data: List of review dicts with 'text', 'rating', 'author'
|
|
|
|
Returns:
|
|
Dict with AI-enhanced sentiment analysis:
|
|
{
|
|
'themes': [{'theme': str, 'sentiment': str, 'count': int}],
|
|
'strengths': [str], # What customers love
|
|
'weaknesses': [str], # What needs improvement
|
|
'overall_sentiment': str, # positive/mixed/negative
|
|
'sentiment_score': float, # -1.0 to 1.0
|
|
'summary': str, # 1-2 sentence summary
|
|
}
|
|
"""
|
|
# Filter reviews with text
|
|
reviews_with_text = [r for r in reviews_data if r.get('text')]
|
|
if not reviews_with_text:
|
|
return None
|
|
|
|
# Build prompt with review texts (max 10 reviews to stay within token limits)
|
|
reviews_text = ""
|
|
for i, r in enumerate(reviews_with_text[:10], 1):
|
|
text = r.get('text', {})
|
|
review_text = text.get('text', '') if isinstance(text, dict) else str(text)
|
|
rating = r.get('rating', '?')
|
|
reviews_text += f"\n{i}. [Ocena: {rating}/5] {review_text[:300]}"
|
|
|
|
prompt = f"""Przeanalizuj poniższe opinie Google dla lokalnej firmy w Polsce.
|
|
|
|
OPINIE:{reviews_text}
|
|
|
|
Odpowiedz WYŁĄCZNIE poprawnym JSON-em (bez markdown, bez komentarzy):
|
|
{{
|
|
"themes": [
|
|
{{"theme": "nazwa tematu", "sentiment": "positive/negative/neutral", "count": N}}
|
|
],
|
|
"strengths": ["co klienci chwalą - max 3 punkty"],
|
|
"weaknesses": ["co wymaga poprawy - max 3 punkty"],
|
|
"overall_sentiment": "positive/mixed/negative",
|
|
"sentiment_score": 0.0,
|
|
"summary": "1-2 zdania podsumowania po polsku"
|
|
}}
|
|
|
|
Gdzie sentiment_score: -1.0 (bardzo negatywny) do 1.0 (bardzo pozytywny).
|
|
Skup się na TREŚCI opinii, nie tylko na ocenach."""
|
|
|
|
try:
|
|
from gemini_service import generate_text
|
|
import json
|
|
|
|
response = generate_text(prompt, temperature=0.3)
|
|
if not response:
|
|
return None
|
|
|
|
# Parse JSON response
|
|
response = response.strip()
|
|
if response.startswith('```'):
|
|
response = response.split('\n', 1)[-1].rsplit('```', 1)[0]
|
|
|
|
return json.loads(response)
|
|
except Exception as e:
|
|
logger.warning(f"AI sentiment analysis failed: {e}")
|
|
return None
|
|
|
|
def check_nap_consistency(self, company: Company,
|
|
website_analysis: 'CompanyWebsiteAnalysis' = None) -> Dict[str, Any]:
|
|
"""
|
|
Check NAP (Name/Address/Phone) consistency between GBP and website.
|
|
|
|
Returns dict with:
|
|
- nap_consistent: bool
|
|
- nap_issues: list of inconsistencies
|
|
"""
|
|
result = {
|
|
'nap_consistent': True,
|
|
'nap_issues': [],
|
|
}
|
|
|
|
if not website_analysis:
|
|
return result
|
|
|
|
# Compare name
|
|
gbp_name = website_analysis.google_name
|
|
website_name = company.name
|
|
if gbp_name and website_name:
|
|
if gbp_name.lower().strip() != website_name.lower().strip():
|
|
result['nap_consistent'] = False
|
|
result['nap_issues'].append({
|
|
'field': 'name',
|
|
'gbp': gbp_name,
|
|
'website': website_name,
|
|
'severity': 'low'
|
|
})
|
|
|
|
# Compare phone
|
|
gbp_phone = website_analysis.google_phone
|
|
company_phone = company.phone
|
|
if gbp_phone and company_phone:
|
|
# Normalize phone numbers for comparison
|
|
gbp_clean = ''.join(c for c in gbp_phone if c.isdigit())
|
|
company_clean = ''.join(c for c in company_phone if c.isdigit())
|
|
# Compare last 9 digits (ignore country code)
|
|
if gbp_clean[-9:] != company_clean[-9:] if len(gbp_clean) >= 9 and len(company_clean) >= 9 else gbp_clean != company_clean:
|
|
result['nap_consistent'] = False
|
|
result['nap_issues'].append({
|
|
'field': 'phone',
|
|
'gbp': gbp_phone,
|
|
'website': company_phone,
|
|
'severity': 'medium'
|
|
})
|
|
|
|
# Compare address
|
|
gbp_address = website_analysis.google_address
|
|
company_address = f"{company.address_street or ''}, {company.address_city or ''}"
|
|
if gbp_address and company.address_city:
|
|
city_lower = company.address_city.lower()
|
|
if city_lower not in gbp_address.lower():
|
|
result['nap_consistent'] = False
|
|
result['nap_issues'].append({
|
|
'field': 'address',
|
|
'gbp': gbp_address,
|
|
'website': company_address.strip(', '),
|
|
'severity': 'high'
|
|
})
|
|
|
|
return result
|
|
|
|
def analyze_photo_categories(self, photos_data: List[Dict] = None) -> Dict[str, int]:
|
|
"""Categorize photos based on available metadata."""
|
|
categories = {
|
|
'total': 0,
|
|
'owner': 0,
|
|
'user': 0,
|
|
}
|
|
|
|
if not photos_data:
|
|
return categories
|
|
|
|
categories['total'] = len(photos_data)
|
|
for photo in photos_data:
|
|
attributions = photo.get('authorAttributions', [])
|
|
is_owner = any('owner' in a.get('displayName', '').lower() or
|
|
'właściciel' in a.get('displayName', '').lower()
|
|
for a in attributions)
|
|
if is_owner:
|
|
categories['owner'] += 1
|
|
else:
|
|
categories['user'] += 1
|
|
|
|
return categories
|
|
|
|
def check_description_keywords(self, company: Company) -> Dict[str, Any]:
|
|
"""Check if business description contains relevant keywords."""
|
|
result = {
|
|
'description_keywords': [],
|
|
'keyword_density_score': 0,
|
|
}
|
|
|
|
desc = company.description_full or company.description_short or ''
|
|
if not desc:
|
|
return result
|
|
|
|
desc_lower = desc.lower()
|
|
|
|
# Check for city name
|
|
city = (company.address_city or '').lower()
|
|
category_name = company.category.name.lower() if company.category else ''
|
|
|
|
found_keywords = []
|
|
|
|
# Check city name in description
|
|
if city and city in desc_lower:
|
|
found_keywords.append(city)
|
|
|
|
# Check category-related terms
|
|
if category_name and category_name in desc_lower:
|
|
found_keywords.append(category_name)
|
|
|
|
# General business keywords
|
|
business_keywords = ['usługi', 'produkty', 'oferta', 'doświadczenie',
|
|
'profesjonalny', 'kontakt', 'zespół', 'specjalizacja']
|
|
for kw in business_keywords:
|
|
if kw in desc_lower:
|
|
found_keywords.append(kw)
|
|
|
|
result['description_keywords'] = found_keywords
|
|
|
|
# Score: 0-100 based on keyword presence
|
|
max_keywords = 5 # ideal number of keywords
|
|
score = min(len(found_keywords) / max_keywords * 100, 100)
|
|
result['keyword_density_score'] = int(score)
|
|
|
|
return result
|
|
|
|
def save_enhanced_audit(self, result: 'AuditResult', enhanced_data: Dict,
|
|
source: str = 'manual') -> 'GBPAudit':
|
|
"""Save audit with enhanced data (reviews, NAP, keywords, photos)."""
|
|
# First save the standard audit
|
|
audit = self.save_audit(result, source)
|
|
|
|
# Then update with enhanced data
|
|
if enhanced_data.get('reviews'):
|
|
reviews = enhanced_data['reviews']
|
|
audit.reviews_with_response = reviews.get('reviews_with_response', 0)
|
|
audit.reviews_without_response = reviews.get('reviews_without_response', 0)
|
|
audit.review_response_rate = reviews.get('review_response_rate', 0.0)
|
|
audit.avg_review_response_days = reviews.get('avg_review_response_days')
|
|
audit.review_sentiment = reviews.get('review_sentiment')
|
|
audit.reviews_30d = reviews.get('reviews_30d', 0)
|
|
audit.review_keywords = reviews.get('review_keywords')
|
|
|
|
if enhanced_data.get('nap'):
|
|
nap = enhanced_data['nap']
|
|
audit.nap_consistent = nap.get('nap_consistent', True)
|
|
audit.nap_issues = nap.get('nap_issues')
|
|
|
|
if enhanced_data.get('keywords'):
|
|
keywords = enhanced_data['keywords']
|
|
audit.description_keywords = keywords.get('description_keywords')
|
|
audit.keyword_density_score = keywords.get('keyword_density_score')
|
|
|
|
if enhanced_data.get('photo_categories'):
|
|
audit.photo_categories = enhanced_data['photo_categories']
|
|
|
|
if enhanced_data.get('attributes'):
|
|
audit.attributes = enhanced_data['attributes']
|
|
|
|
if enhanced_data.get('hours'):
|
|
hours = enhanced_data['hours']
|
|
audit.has_special_hours = hours.get('has_special_hours', False)
|
|
audit.special_hours = hours.get('special_hours')
|
|
|
|
self.db.commit()
|
|
self.db.refresh(audit)
|
|
return audit
|
|
|
|
def save_reviews(self, company_id: int, reviews_data: List[Dict]) -> int:
|
|
"""Save individual reviews to gbp_reviews table. Returns count saved."""
|
|
saved = 0
|
|
for review in reviews_data:
|
|
review_id = review.get('name', '') or f"r_{review.get('author', 'anon')}_{review.get('time', '')}"
|
|
|
|
existing = self.db.query(GBPReview).filter(
|
|
GBPReview.company_id == company_id,
|
|
GBPReview.google_review_id == review_id
|
|
).first()
|
|
|
|
if not existing:
|
|
gbp_review = GBPReview(
|
|
company_id=company_id,
|
|
google_review_id=review_id,
|
|
author_name=review.get('author', 'Anonim'),
|
|
rating=review.get('rating', 0),
|
|
text=review.get('text', ''),
|
|
publish_time=review.get('time'),
|
|
sentiment=self._classify_sentiment(review.get('rating', 0)),
|
|
)
|
|
self.db.add(gbp_review)
|
|
saved += 1
|
|
|
|
if saved:
|
|
self.db.commit()
|
|
return saved
|
|
|
|
@staticmethod
|
|
def _classify_sentiment(rating: int) -> str:
|
|
"""Classify review sentiment based on rating."""
|
|
if rating >= 4:
|
|
return 'positive'
|
|
elif rating == 3:
|
|
return 'neutral'
|
|
else:
|
|
return 'negative'
|
|
|
|
# === AI-Powered Recommendations ===
|
|
|
|
def generate_ai_recommendations(
|
|
self,
|
|
company: Company,
|
|
result: AuditResult,
|
|
user_id: Optional[int] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Generate AI-powered recommendations using Gemini.
|
|
|
|
Args:
|
|
company: Company being audited
|
|
result: AuditResult from the audit
|
|
user_id: Optional user ID for cost tracking
|
|
|
|
Returns:
|
|
List of AI-generated recommendation dicts with keys:
|
|
- priority: 'high', 'medium', 'low'
|
|
- field: field name this applies to
|
|
- recommendation: AI-generated recommendation text
|
|
- action_steps: list of specific action steps
|
|
- expected_impact: description of expected improvement
|
|
"""
|
|
service = gemini_service.get_gemini_service()
|
|
if not service:
|
|
logger.warning("Gemini service not available - using static recommendations")
|
|
return result.recommendations
|
|
|
|
try:
|
|
# Build context for AI
|
|
prompt = self._build_ai_recommendation_prompt(company, result)
|
|
|
|
# Call Gemini with cost tracking
|
|
response_text = service.generate_text(
|
|
prompt=prompt,
|
|
feature='gbp_audit_ai',
|
|
user_id=user_id,
|
|
temperature=0.7,
|
|
max_tokens=2000,
|
|
model='3-pro'
|
|
)
|
|
|
|
# Parse AI response
|
|
ai_recommendations = self._parse_ai_recommendations(response_text, result)
|
|
|
|
logger.info(
|
|
f"AI recommendations generated for company {company.id}: "
|
|
f"{len(ai_recommendations)} recommendations"
|
|
)
|
|
|
|
return ai_recommendations
|
|
|
|
except Exception as e:
|
|
logger.error(f"AI recommendation generation failed: {e}")
|
|
# Fall back to static recommendations
|
|
return result.recommendations
|
|
|
|
def _build_ai_recommendation_prompt(
|
|
self,
|
|
company: Company,
|
|
result: AuditResult
|
|
) -> str:
|
|
"""
|
|
Build prompt for Gemini to generate personalized recommendations.
|
|
|
|
Args:
|
|
company: Company being audited
|
|
result: AuditResult with field statuses
|
|
|
|
Returns:
|
|
Formatted prompt string
|
|
"""
|
|
# Build field status summary
|
|
field_summary = []
|
|
for field_name, field_status in result.fields.items():
|
|
status_emoji = {
|
|
'complete': '✅',
|
|
'partial': '⚠️',
|
|
'missing': '❌'
|
|
}.get(field_status.status, '❓')
|
|
|
|
field_summary.append(
|
|
f"- {field_name}: {status_emoji} {field_status.status} "
|
|
f"({field_status.score:.1f}/{field_status.max_score:.1f} pkt)"
|
|
)
|
|
|
|
# Get category info
|
|
category_name = company.category.name if company.category else 'Nieznana'
|
|
|
|
prompt = f"""Jesteś ekspertem od Google Business Profile (Wizytówki Google) i lokalnego SEO.
|
|
|
|
FIRMA: {company.name}
|
|
BRANŻA: {category_name}
|
|
MIASTO: {company.address_city or 'Nieznane'}
|
|
WYNIK AUDYTU: {result.completeness_score}/100
|
|
|
|
STATUS PÓL WIZYTÓWKI:
|
|
{chr(10).join(field_summary)}
|
|
|
|
LICZBA ZDJĘĆ: {result.photo_count}
|
|
LICZBA OPINII: {result.review_count}
|
|
OCENA: {result.average_rating or 'Brak'}
|
|
|
|
ZADANIE:
|
|
Wygeneruj 3-5 spersonalizowanych rekomendacji dla tej firmy, aby poprawić jej wizytówkę Google.
|
|
|
|
WYMAGANIA:
|
|
1. Każda rekomendacja powinna być konkretna i dostosowana do branży firmy
|
|
2. Skup się na polach z najniższymi wynikami
|
|
3. Podaj praktyczne kroki do wykonania
|
|
4. Używaj języka polskiego
|
|
|
|
ZWRÓĆ ODPOWIEDŹ W FORMACIE JSON (TYLKO JSON, BEZ MARKDOWN):
|
|
[
|
|
{{
|
|
"priority": "high|medium|low",
|
|
"field": "nazwa_pola",
|
|
"recommendation": "Krótki opis co poprawić",
|
|
"action_steps": ["Krok 1", "Krok 2", "Krok 3"],
|
|
"expected_impact": "Opis spodziewanej poprawy"
|
|
}}
|
|
]
|
|
|
|
Priorytety:
|
|
- high: kluczowe pola (name, address, categories, description)
|
|
- medium: ważne pola (phone, website, photos, services)
|
|
- low: dodatkowe pola (hours, reviews)
|
|
|
|
Odpowiedź (TYLKO JSON):"""
|
|
|
|
return prompt
|
|
|
|
def _parse_ai_recommendations(
|
|
self,
|
|
response_text: str,
|
|
fallback_result: AuditResult
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Parse AI response into structured recommendations.
|
|
|
|
Args:
|
|
response_text: Raw text from Gemini
|
|
fallback_result: AuditResult to use for fallback
|
|
|
|
Returns:
|
|
List of recommendation dicts
|
|
"""
|
|
try:
|
|
# Clean up response - remove markdown code blocks if present
|
|
cleaned = response_text.strip()
|
|
if cleaned.startswith('```'):
|
|
# Remove markdown code block markers
|
|
lines = cleaned.split('\n')
|
|
# Find JSON content between ``` markers
|
|
json_lines = []
|
|
in_json = False
|
|
for line in lines:
|
|
if line.startswith('```') and not in_json:
|
|
in_json = True
|
|
continue
|
|
elif line.startswith('```') and in_json:
|
|
break
|
|
elif in_json:
|
|
json_lines.append(line)
|
|
cleaned = '\n'.join(json_lines)
|
|
|
|
# Parse JSON
|
|
recommendations = json.loads(cleaned)
|
|
|
|
# Validate and enhance recommendations
|
|
valid_recommendations = []
|
|
valid_priorities = {'high', 'medium', 'low'}
|
|
valid_fields = set(FIELD_WEIGHTS.keys())
|
|
|
|
for rec in recommendations:
|
|
if not isinstance(rec, dict):
|
|
continue
|
|
|
|
# Validate priority
|
|
priority = rec.get('priority', 'medium')
|
|
if priority not in valid_priorities:
|
|
priority = 'medium'
|
|
|
|
# Validate field
|
|
field = rec.get('field', 'general')
|
|
if field not in valid_fields:
|
|
field = 'general'
|
|
|
|
# Get impact score from field weights
|
|
impact = FIELD_WEIGHTS.get(field, 5)
|
|
|
|
valid_recommendations.append({
|
|
'priority': priority,
|
|
'field': field,
|
|
'recommendation': rec.get('recommendation', ''),
|
|
'action_steps': rec.get('action_steps', []),
|
|
'expected_impact': rec.get('expected_impact', ''),
|
|
'impact': impact,
|
|
'source': 'ai'
|
|
})
|
|
|
|
if valid_recommendations:
|
|
# Sort by priority and impact
|
|
priority_order = {'high': 0, 'medium': 1, 'low': 2}
|
|
valid_recommendations.sort(
|
|
key=lambda x: (priority_order.get(x['priority'], 3), -x['impact'])
|
|
)
|
|
return valid_recommendations
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"Failed to parse AI recommendations JSON: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Error processing AI recommendations: {e}")
|
|
|
|
# Return fallback recommendations with source marker
|
|
fallback = []
|
|
for rec in fallback_result.recommendations:
|
|
rec_copy = dict(rec)
|
|
rec_copy['source'] = 'static'
|
|
rec_copy['action_steps'] = []
|
|
rec_copy['expected_impact'] = ''
|
|
fallback.append(rec_copy)
|
|
|
|
return fallback
|
|
|
|
def audit_with_ai(
|
|
self,
|
|
company_id: int,
|
|
user_id: Optional[int] = None
|
|
) -> AuditResult:
|
|
"""
|
|
Run full GBP audit with AI-powered recommendations.
|
|
|
|
Args:
|
|
company_id: ID of the company to audit
|
|
user_id: Optional user ID for cost tracking
|
|
|
|
Returns:
|
|
AuditResult with AI-enhanced recommendations
|
|
"""
|
|
# Run standard audit
|
|
result = self.audit_company(company_id)
|
|
|
|
# Get company for AI context
|
|
company = self.db.query(Company).filter(Company.id == company_id).first()
|
|
if not company:
|
|
return result
|
|
|
|
# Generate AI recommendations
|
|
ai_recommendations = self.generate_ai_recommendations(
|
|
company=company,
|
|
result=result,
|
|
user_id=user_id
|
|
)
|
|
|
|
# Replace static recommendations with AI-generated ones
|
|
result.recommendations = ai_recommendations
|
|
|
|
return result
|
|
|
|
|
|
# === Convenience Functions ===
|
|
|
|
def audit_company(db: Session, company_id: int, save: bool = True) -> AuditResult:
|
|
"""
|
|
Audit a company's GBP completeness.
|
|
|
|
Args:
|
|
db: Database session
|
|
company_id: Company ID to audit
|
|
save: Whether to save audit to database
|
|
|
|
Returns:
|
|
AuditResult with completeness score and recommendations
|
|
"""
|
|
service = GBPAuditService(db)
|
|
result = service.audit_company(company_id)
|
|
|
|
if save:
|
|
service.save_audit(result)
|
|
|
|
return result
|
|
|
|
|
|
def get_company_audit(db: Session, company_id: int) -> Optional[GBPAudit]:
|
|
"""
|
|
Get the latest audit for a company.
|
|
|
|
Args:
|
|
db: Database session
|
|
company_id: Company ID
|
|
|
|
Returns:
|
|
Latest GBPAudit or None
|
|
"""
|
|
service = GBPAuditService(db)
|
|
return service.get_latest_audit(company_id)
|
|
|
|
|
|
def audit_company_with_ai(
|
|
db: Session,
|
|
company_id: int,
|
|
save: bool = True,
|
|
user_id: Optional[int] = None
|
|
) -> AuditResult:
|
|
"""
|
|
Audit a company's GBP completeness with AI-powered recommendations.
|
|
|
|
Args:
|
|
db: Database session
|
|
company_id: Company ID to audit
|
|
save: Whether to save audit to database
|
|
user_id: Optional user ID for cost tracking
|
|
|
|
Returns:
|
|
AuditResult with AI-enhanced recommendations
|
|
"""
|
|
service = GBPAuditService(db)
|
|
result = service.audit_with_ai(company_id, user_id=user_id)
|
|
|
|
if save:
|
|
service.save_audit(result, source='ai')
|
|
|
|
return result
|
|
|
|
|
|
def batch_audit_companies(
|
|
db: Session,
|
|
company_ids: Optional[List[int]] = None,
|
|
save: bool = True
|
|
) -> Dict[int, AuditResult]:
|
|
"""
|
|
Audit multiple companies.
|
|
|
|
Args:
|
|
db: Database session
|
|
company_ids: List of company IDs (None = all active companies)
|
|
save: Whether to save audits to database
|
|
|
|
Returns:
|
|
Dict mapping company_id to AuditResult
|
|
"""
|
|
service = GBPAuditService(db)
|
|
|
|
# Get companies to audit
|
|
if company_ids is None:
|
|
companies = db.query(Company).filter(Company.status == 'active').all()
|
|
company_ids = [c.id for c in companies]
|
|
|
|
results = {}
|
|
for company_id in company_ids:
|
|
try:
|
|
result = service.audit_company(company_id)
|
|
if save:
|
|
service.save_audit(result, source='automated')
|
|
results[company_id] = result
|
|
except Exception as e:
|
|
logger.error(f"Failed to audit company {company_id}: {e}")
|
|
|
|
return results
|
|
|
|
|
|
# === Google Places API Integration ===
|
|
|
|
def fetch_google_business_data(
|
|
db: Session,
|
|
company_id: int,
|
|
force_refresh: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Fetch Google Business Profile data using Places API (New)."""
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
result = {
|
|
'success': False,
|
|
'steps': [],
|
|
'data': {},
|
|
'error': None
|
|
}
|
|
|
|
company = db.query(Company).filter(Company.id == company_id).first()
|
|
if not company:
|
|
result['error'] = f'Firma o ID {company_id} nie znaleziona'
|
|
return result
|
|
|
|
# Cache check (identical to current)
|
|
if not force_refresh:
|
|
existing = db.query(CompanyWebsiteAnalysis).filter(
|
|
CompanyWebsiteAnalysis.company_id == company_id
|
|
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
|
|
|
|
if existing and existing.analyzed_at:
|
|
age = datetime.now() - existing.analyzed_at
|
|
if age < timedelta(hours=24) and existing.google_place_id:
|
|
result['success'] = True
|
|
result['steps'].append({
|
|
'step': 'cache_check',
|
|
'status': 'skipped',
|
|
'message': f'Dane pobrano {age.seconds // 3600}h temu - używam cache'
|
|
})
|
|
result['data'] = {
|
|
'google_place_id': existing.google_place_id,
|
|
'google_rating': float(existing.google_rating) if existing.google_rating else None,
|
|
'google_reviews_count': existing.google_reviews_count,
|
|
'google_photos_count': existing.google_photos_count,
|
|
'google_opening_hours': existing.google_opening_hours,
|
|
'cached': True
|
|
}
|
|
return result
|
|
|
|
# Initialize Places API service
|
|
try:
|
|
places_service = GooglePlacesService()
|
|
except ValueError as e:
|
|
result['error'] = str(e)
|
|
result['steps'].append({
|
|
'step': 'api_key_check',
|
|
'status': 'error',
|
|
'message': str(e)
|
|
})
|
|
return result
|
|
|
|
result['steps'].append({
|
|
'step': 'api_key_check',
|
|
'status': 'complete',
|
|
'message': 'Places API (New) skonfigurowany'
|
|
})
|
|
|
|
# Step 1: Search for place
|
|
result['steps'].append({
|
|
'step': 'find_place',
|
|
'status': 'in_progress',
|
|
'message': f'Szukam firmy "{company.name}" w Google Maps...'
|
|
})
|
|
|
|
city = company.address_city or 'Wejherowo'
|
|
search_query = f'{company.name} {city}'
|
|
|
|
# Use Wejherowo coordinates as location bias (most companies are local)
|
|
location_bias = {'latitude': 54.6059, 'longitude': 18.2350, 'radius': 50000.0}
|
|
|
|
place_result = places_service.search_place(search_query, location_bias=location_bias, company_name=company.name)
|
|
|
|
if not place_result:
|
|
result['steps'][-1]['status'] = 'warning'
|
|
result['steps'][-1]['message'] = 'Nie znaleziono firmy w Google Maps'
|
|
result['error'] = 'Firma nie ma profilu Google Business lub nazwa jest inna niż w Google'
|
|
return result
|
|
|
|
place_id = place_result.get('id', '')
|
|
# Places API (New) returns id without 'places/' prefix in search, but needs it for details
|
|
if not place_id.startswith('places/'):
|
|
place_id_for_details = place_id
|
|
else:
|
|
place_id_for_details = place_id.replace('places/', '')
|
|
|
|
google_name = place_result.get('displayName', {}).get('text', '')
|
|
google_address = place_result.get('formattedAddress', '')
|
|
|
|
result['steps'][-1]['status'] = 'complete'
|
|
result['steps'][-1]['message'] = f'Znaleziono: {google_name}'
|
|
result['data']['google_place_id'] = place_id_for_details
|
|
result['data']['google_name'] = google_name
|
|
result['data']['google_address'] = google_address
|
|
|
|
# Step 2: Get full place details
|
|
result['steps'].append({
|
|
'step': 'get_details',
|
|
'status': 'in_progress',
|
|
'message': 'Pobieram szczegóły wizytówki (Places API New)...'
|
|
})
|
|
|
|
place_data = places_service.get_place_details(
|
|
place_id_for_details,
|
|
include_reviews=True,
|
|
include_photos=True,
|
|
include_attributes=True
|
|
)
|
|
|
|
if not place_data:
|
|
result['steps'][-1]['status'] = 'warning'
|
|
result['steps'][-1]['message'] = 'Nie udało się pobrać szczegółów'
|
|
result['error'] = 'Błąd pobierania szczegółów z Places API (New)'
|
|
return result
|
|
|
|
# Extract all data from Places API (New)
|
|
google_name = place_data.get('displayName', {}).get('text', google_name)
|
|
google_address = place_data.get('formattedAddress', google_address)
|
|
phone = place_data.get('nationalPhoneNumber') or place_data.get('internationalPhoneNumber')
|
|
website = place_data.get('websiteUri')
|
|
types = place_data.get('types', [])
|
|
primary_type = place_data.get('primaryType', '')
|
|
maps_url = place_data.get('googleMapsUri', '')
|
|
rating = place_data.get('rating')
|
|
reviews_count = place_data.get('userRatingCount')
|
|
business_status = place_data.get('businessStatus', '')
|
|
editorial_summary = place_data.get('editorialSummary', {}).get('text', '')
|
|
price_level = place_data.get('priceLevel', '')
|
|
maps_links = place_data.get('googleMapsLinks', {})
|
|
current_hours = place_data.get('currentOpeningHours', {})
|
|
open_now = current_hours.get('openNow')
|
|
|
|
# Extract rich data using service methods
|
|
reviews_data = places_service.extract_reviews_data(place_data)
|
|
attributes = places_service.extract_attributes(place_data)
|
|
hours_data = places_service.extract_hours(place_data)
|
|
photos_meta = places_service.extract_photos_metadata(place_data)
|
|
|
|
photos_count = photos_meta.get('total_count', 0)
|
|
|
|
# Build opening hours dict (backward-compatible format)
|
|
opening_hours = {}
|
|
if hours_data.get('regular'):
|
|
opening_hours = {
|
|
'weekday_text': hours_data['regular'].get('weekday_descriptions', []),
|
|
'open_now': hours_data['regular'].get('open_now'),
|
|
'periods': hours_data['regular'].get('periods', [])
|
|
}
|
|
|
|
# Store in result data (backward-compatible fields)
|
|
result['data'].update({
|
|
'google_name': google_name,
|
|
'google_address': google_address,
|
|
'google_phone': phone,
|
|
'google_website': website,
|
|
'google_types': types,
|
|
'google_maps_url': maps_url,
|
|
'google_rating': rating,
|
|
'google_reviews_count': reviews_count,
|
|
'google_photos_count': photos_count,
|
|
'google_opening_hours': opening_hours,
|
|
'google_business_status': business_status,
|
|
# NEW fields from Places API (New)
|
|
'google_primary_type': primary_type,
|
|
'google_editorial_summary': editorial_summary,
|
|
'google_price_level': price_level,
|
|
'google_attributes': attributes,
|
|
'google_reviews_data': reviews_data,
|
|
'google_photos_metadata': photos_meta,
|
|
'google_has_special_hours': hours_data.get('has_special_hours', False),
|
|
'google_maps_links': maps_links if maps_links else None,
|
|
'google_open_now': open_now,
|
|
})
|
|
|
|
result['steps'][-1]['status'] = 'complete'
|
|
details_msg = []
|
|
if rating:
|
|
details_msg.append(f'Ocena: {rating}')
|
|
if reviews_count:
|
|
details_msg.append(f'{reviews_count} opinii')
|
|
if photos_count:
|
|
details_msg.append(f'{photos_count} zdjęć')
|
|
if attributes:
|
|
details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów')
|
|
result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane'
|
|
|
|
# OAuth: Try GBP Management API for owner-specific data
|
|
try:
|
|
from oauth_service import OAuthService
|
|
from gbp_management_service import GBPManagementService
|
|
|
|
oauth = OAuthService()
|
|
gbp_token = oauth.get_valid_token(db, company_id, 'google', 'gbp')
|
|
if gbp_token:
|
|
token_record = db.query(OAuthToken).filter(
|
|
OAuthToken.company_id == company_id,
|
|
OAuthToken.provider == 'google',
|
|
OAuthToken.service == 'gbp',
|
|
OAuthToken.is_active == True,
|
|
).first()
|
|
location_name = None
|
|
if token_record and token_record.metadata_json:
|
|
location_name = token_record.metadata_json.get('location_name')
|
|
|
|
if location_name:
|
|
gbp_mgmt = GBPManagementService(gbp_token)
|
|
reviews = gbp_mgmt.get_reviews(location_name)
|
|
if reviews:
|
|
owner_responses = sum(1 for r in reviews if r.get('reviewReply'))
|
|
result['data']['google_owner_responses_count'] = owner_responses
|
|
result['data']['google_total_reviews_with_replies'] = len(reviews)
|
|
result['data']['google_review_response_rate'] = round(
|
|
owner_responses / len(reviews) * 100, 1
|
|
) if reviews else 0
|
|
|
|
posts = gbp_mgmt.get_local_posts(location_name)
|
|
if posts:
|
|
result['data']['google_posts_count'] = len(posts)
|
|
result['data']['google_posts_data'] = posts[:10]
|
|
|
|
logger.info(f"OAuth GBP enrichment: {len(reviews or [])} reviews, {len(posts or [])} posts for company {company_id}")
|
|
except Exception as e:
|
|
logger.warning(f"OAuth GBP enrichment failed for company {company_id}: {e}")
|
|
|
|
# OAuth: Try GBP Performance API for visibility metrics
|
|
try:
|
|
from gbp_performance_service import GBPPerformanceService
|
|
|
|
if gbp_token and location_name:
|
|
perf_service = GBPPerformanceService(gbp_token)
|
|
# Extract location ID from location_name (format: accounts/X/locations/Y)
|
|
# Performance API uses locations/Y format
|
|
parts = location_name.split('/')
|
|
if len(parts) >= 4:
|
|
perf_location = f"locations/{parts[3]}"
|
|
else:
|
|
perf_location = location_name
|
|
|
|
perf_data = perf_service.get_all_performance_data(perf_location, days=30)
|
|
if perf_data:
|
|
result['data']['gbp_impressions_maps'] = perf_data.get('maps_impressions', 0)
|
|
result['data']['gbp_impressions_search'] = perf_data.get('search_impressions', 0)
|
|
result['data']['gbp_call_clicks'] = perf_data.get('call_clicks', 0)
|
|
result['data']['gbp_website_clicks'] = perf_data.get('website_clicks', 0)
|
|
result['data']['gbp_direction_requests'] = perf_data.get('direction_requests', 0)
|
|
result['data']['gbp_conversations'] = perf_data.get('conversations', 0)
|
|
result['data']['gbp_search_keywords'] = perf_data.get('search_keywords', [])
|
|
result['data']['gbp_performance_period_days'] = perf_data.get('period_days', 30)
|
|
logger.info(f"GBP Performance data collected for company {company_id}")
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"GBP Performance API failed for company {company_id}: {e}")
|
|
|
|
# Step 3: Save to database
|
|
result['steps'].append({
|
|
'step': 'save_data',
|
|
'status': 'in_progress',
|
|
'message': 'Zapisuję dane w bazie...'
|
|
})
|
|
|
|
try:
|
|
analysis = db.query(CompanyWebsiteAnalysis).filter(
|
|
CompanyWebsiteAnalysis.company_id == company_id
|
|
).first()
|
|
|
|
if not analysis:
|
|
analysis = CompanyWebsiteAnalysis(
|
|
company_id=company_id,
|
|
url=company.website,
|
|
analyzed_at=datetime.now()
|
|
)
|
|
db.add(analysis)
|
|
|
|
# Update Google fields (same as before)
|
|
analysis.google_place_id = place_id_for_details
|
|
analysis.google_name = google_name
|
|
analysis.google_address = google_address
|
|
analysis.google_phone = phone
|
|
analysis.google_website = website
|
|
analysis.google_types = types if types else None
|
|
analysis.google_maps_url = maps_url
|
|
analysis.google_rating = rating
|
|
analysis.google_reviews_count = reviews_count
|
|
analysis.google_photos_count = photos_count
|
|
analysis.google_opening_hours = opening_hours if opening_hours else None
|
|
analysis.google_business_status = business_status
|
|
analysis.analyzed_at = datetime.now()
|
|
|
|
# NEW: Save additional Places API (New) data to JSONB fields if they exist
|
|
# Use setattr with try/except for new columns that may not exist yet
|
|
for attr, val in [
|
|
('google_primary_type', primary_type),
|
|
('google_editorial_summary', editorial_summary),
|
|
('google_price_level', price_level),
|
|
('google_attributes', attributes if attributes else None),
|
|
('google_reviews_data', reviews_data if reviews_data else None),
|
|
('google_photos_metadata', photos_meta if photos_meta else None),
|
|
('google_maps_links', maps_links if maps_links else None),
|
|
('google_open_now', open_now),
|
|
('gbp_impressions_maps', result['data'].get('gbp_impressions_maps')),
|
|
('gbp_impressions_search', result['data'].get('gbp_impressions_search')),
|
|
('gbp_call_clicks', result['data'].get('gbp_call_clicks')),
|
|
('gbp_website_clicks', result['data'].get('gbp_website_clicks')),
|
|
('gbp_direction_requests', result['data'].get('gbp_direction_requests')),
|
|
('gbp_conversations', result['data'].get('gbp_conversations')),
|
|
('gbp_search_keywords', result['data'].get('gbp_search_keywords')),
|
|
('gbp_performance_period_days', result['data'].get('gbp_performance_period_days')),
|
|
('google_owner_responses_count', result['data'].get('google_owner_responses_count')),
|
|
('google_review_response_rate', result['data'].get('google_review_response_rate')),
|
|
('google_posts_data', result['data'].get('google_posts_data')),
|
|
('google_posts_count', result['data'].get('google_posts_count')),
|
|
]:
|
|
try:
|
|
setattr(analysis, attr, val)
|
|
except Exception:
|
|
pass # Column doesn't exist yet, skip
|
|
|
|
db.commit()
|
|
|
|
result['steps'][-1]['status'] = 'complete'
|
|
result['steps'][-1]['message'] = 'Dane zapisane pomyślnie'
|
|
result['success'] = True
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
result['steps'][-1]['status'] = 'error'
|
|
result['steps'][-1]['message'] = f'Błąd zapisu: {str(e)}'
|
|
result['error'] = f'Błąd zapisu do bazy danych: {str(e)}'
|
|
return result
|
|
|
|
logger.info(
|
|
f"Google data fetched via Places API (New) for company {company_id}: "
|
|
f"rating={rating}, reviews={reviews_count}, photos={photos_count}, "
|
|
f"attributes={len(attributes)} categories"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# === Main for Testing ===
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
|
|
# Test the service
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
# Check for --ai flag to test AI recommendations
|
|
use_ai = '--ai' in sys.argv
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# Get first active company
|
|
company = db.query(Company).filter(Company.status == 'active').first()
|
|
if company:
|
|
print(f"\nAuditing company: {company.name} (ID: {company.id})")
|
|
print("-" * 50)
|
|
|
|
if use_ai:
|
|
print("\n[AI MODE] Generating AI-powered recommendations...")
|
|
result = audit_company_with_ai(db, company.id, save=False)
|
|
else:
|
|
result = audit_company(db, company.id, save=False)
|
|
|
|
print(f"\nCompleteness Score: {result.completeness_score}/100")
|
|
print(f"\nField Status:")
|
|
for name, field in result.fields.items():
|
|
status_icon = {'complete': '✅', 'partial': '⚠️', 'missing': '❌'}.get(field.status, '?')
|
|
print(f" {status_icon} {name}: {field.status} ({field.score:.1f}/{field.max_score:.1f})")
|
|
|
|
print(f"\nRecommendations ({len(result.recommendations)}):")
|
|
for rec in result.recommendations[:5]:
|
|
source = rec.get('source', 'static')
|
|
source_label = '[AI]' if source == 'ai' else '[STATIC]'
|
|
print(f"\n {source_label} [{rec['priority'].upper()}] {rec['field']}:")
|
|
print(f" {rec['recommendation']}")
|
|
|
|
# Print AI-specific fields if present
|
|
if rec.get('action_steps'):
|
|
print(" Action steps:")
|
|
for step in rec['action_steps']:
|
|
print(f" • {step}")
|
|
|
|
if rec.get('expected_impact'):
|
|
print(f" Expected impact: {rec['expected_impact']}")
|
|
else:
|
|
print("No active companies found")
|
|
|
|
print("\n" + "-" * 50)
|
|
print("Usage: python gbp_audit_service.py [--ai]")
|
|
print(" --ai Generate AI-powered recommendations using Gemini")
|
|
|
|
finally:
|
|
db.close()
|