This commit is contained in:
oliver
2026-08-28 10:11:11 -03:00
parent f533ff8e36
commit 585951c8ce
1380 changed files with 1540268 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Service modules for external API integrations."""
from .supabase import SupabaseService
from .apify import ApifyService
from .firecrawl import FirecrawlService
from .haiku import HaikuService
from .images import ImageService
from .tags import TagAggregationService
__all__ = [
"SupabaseService",
"ApifyService",
"FirecrawlService",
"HaikuService",
"ImageService",
"TagAggregationService",
]
+224
View File
@@ -0,0 +1,224 @@
"""
Apify service for Google Maps scraping.
"""
import logging
from typing import List, Optional, Dict, Any
import time
import httpx
from ..config import get_settings, APIFY_GOOGLE_MAPS_ACTOR, SEARCH_QUERIES
from ..models.schemas import ApifyGoogleMapsResult
from ..utils.retry import retry_with_backoff
logger = logging.getLogger(__name__)
class ApifyService:
"""Service for interacting with Apify Google Maps scraper."""
BASE_URL = "https://api.apify.com/v2"
def __init__(self):
"""Initialize Apify client."""
settings = get_settings()
self.token = settings.apify_token
self.headers = {"Authorization": f"Bearer {self.token}"}
def _get_search_queries(self, city: str, language: str = "de") -> List[str]:
"""
Generate search queries for a city.
Args:
city: City name
language: Language code for queries
Returns:
List of search query strings
"""
templates = SEARCH_QUERIES.get(language, SEARCH_QUERIES["en"])
return [template.format(city=city) for template in templates]
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
def run_google_maps_scraper(
self,
search_queries: List[str],
max_results_per_query: int = 20,
language: str = "de",
country: str = "DE",
) -> str:
"""
Start a Google Maps scraper run.
Args:
search_queries: List of search queries
max_results_per_query: Max results per query
language: Language for results
country: Country code for search
Returns:
Run ID for tracking
"""
url = f"{self.BASE_URL}/acts/{APIFY_GOOGLE_MAPS_ACTOR}/runs"
payload = {
"searchStringsArray": search_queries,
"maxCrawledPlaces": max_results_per_query * len(search_queries),
"language": language,
"countryCode": country,
"maxReviews": 0, # Don't fetch reviews to save credits
"maxImages": 0, # Don't fetch images
"includeWebResults": False,
}
with httpx.Client(timeout=60.0) as client:
response = client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
run_id = data["data"]["id"]
logger.info(f"Started Apify run: {run_id}")
return run_id
def wait_for_run(
self, run_id: str, timeout: int = 300, poll_interval: int = 10
) -> str:
"""
Wait for an Apify run to complete.
Args:
run_id: Run ID to wait for
timeout: Maximum wait time in seconds
poll_interval: Time between status checks
Returns:
Final status of the run
"""
url = f"{self.BASE_URL}/actor-runs/{run_id}"
start_time = time.time()
while True:
if time.time() - start_time > timeout:
raise TimeoutError(f"Apify run {run_id} timed out after {timeout}s")
with httpx.Client(timeout=30.0) as client:
response = client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
status = data["data"]["status"]
logger.debug(f"Run {run_id} status: {status}")
if status in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
return status
time.sleep(poll_interval)
def get_run_results(self, run_id: str) -> List[ApifyGoogleMapsResult]:
"""
Get results from a completed Apify run.
Args:
run_id: Run ID to get results for
Returns:
List of Google Maps results
"""
url = f"{self.BASE_URL}/actor-runs/{run_id}/dataset/items"
with httpx.Client(timeout=60.0) as client:
response = client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
results = []
for item in data:
try:
result = ApifyGoogleMapsResult(
title=item.get("title", ""),
address=item.get("address"),
phone=item.get("phone"),
website=item.get("website"),
rating=item.get("totalScore"),
reviewsCount=item.get("reviewsCount"),
placeId=item.get("placeId"),
url=item.get("url"),
location=item.get("location"),
)
results.append(result)
except Exception as e:
logger.warning(f"Failed to parse result: {e}")
logger.info(f"Retrieved {len(results)} results from run {run_id}")
return results
def scrape_city(
self,
city_name: str,
language: str = "de",
country: str = "DE",
max_per_query: int = 20,
) -> List[ApifyGoogleMapsResult]:
"""
Scrape Google Maps for Odoo partners in a city.
This is the main high-level method that combines query generation,
running the scraper, and retrieving results.
Args:
city_name: Name of the city to scrape
language: Language code
country: Country code
max_per_query: Max results per search query
Returns:
List of Google Maps results
"""
queries = self._get_search_queries(city_name, language)
logger.info(f"Scraping {city_name} with {len(queries)} queries")
# Start the run
run_id = self.run_google_maps_scraper(
search_queries=queries,
max_results_per_query=max_per_query,
language=language,
country=country,
)
# Wait for completion
status = self.wait_for_run(run_id)
if status != "SUCCEEDED":
raise RuntimeError(f"Apify run failed with status: {status}")
# Get results
return self.get_run_results(run_id)
def deduplicate_results(
self, results: List[ApifyGoogleMapsResult]
) -> List[ApifyGoogleMapsResult]:
"""
Remove duplicate results based on place ID.
Args:
results: List of results to deduplicate
Returns:
Deduplicated list
"""
seen_place_ids = set()
unique_results = []
for result in results:
if result.placeId and result.placeId not in seen_place_ids:
seen_place_ids.add(result.placeId)
unique_results.append(result)
elif not result.placeId:
# Keep results without place ID but log warning
logger.warning(f"Result without place ID: {result.title}")
unique_results.append(result)
logger.info(
f"Deduplicated {len(results)} results to {len(unique_results)} unique"
)
return unique_results
+177
View File
@@ -0,0 +1,177 @@
"""
Firecrawl service for website scraping and enrichment.
"""
import logging
from typing import Optional, Dict, Any, List
import httpx
from ..config import get_settings
from ..models.schemas import FirecrawlResult
from ..utils.retry import retry_with_backoff
logger = logging.getLogger(__name__)
class FirecrawlService:
"""Service for scraping websites using Firecrawl."""
BASE_URL = "https://api.firecrawl.dev/v1"
def __init__(self):
"""Initialize Firecrawl client."""
settings = get_settings()
self.api_key = settings.firecrawl_api_key
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
def scrape_url(
self,
url: str,
formats: Optional[List[str]] = None,
include_images: bool = True,
) -> FirecrawlResult:
"""
Scrape a single URL.
Args:
url: URL to scrape
formats: Output formats (default: markdown)
include_images: Whether to extract image URLs
Returns:
Scraped content
"""
if formats is None:
formats = ["markdown"]
endpoint = f"{self.BASE_URL}/scrape"
payload = {
"url": url,
"formats": formats,
}
with httpx.Client(timeout=60.0) as client:
response = client.post(endpoint, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
if not data.get("success"):
raise RuntimeError(f"Firecrawl scrape failed: {data.get('error')}")
result_data = data.get("data", {})
# Extract images from metadata if available
images = []
if include_images:
metadata = result_data.get("metadata", {})
# Try to get og:image and other image sources
if metadata.get("ogImage"):
images.append(metadata["ogImage"])
if metadata.get("logo"):
images.append(metadata["logo"])
return FirecrawlResult(
markdown=result_data.get("markdown"),
metadata=result_data.get("metadata"),
images=images,
)
def extract_company_info(
self, url: str
) -> Dict[str, Any]:
"""
Extract structured company information from a website.
Args:
url: Company website URL
Returns:
Dictionary with extracted information
"""
try:
result = self.scrape_url(url, formats=["markdown"], include_images=True)
info = {
"markdown": result.markdown,
"images": result.images or [],
"logo_url": None,
"email": None,
"title": None,
"description": None,
}
# Extract metadata
if result.metadata:
info["title"] = result.metadata.get("title")
info["description"] = result.metadata.get("description")
if result.metadata.get("ogImage"):
info["logo_url"] = result.metadata["ogImage"]
# Try to find email in markdown content
if result.markdown:
import re
email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
emails = re.findall(email_pattern, result.markdown)
if emails:
# Filter out common non-company emails
company_emails = [
e for e in emails
if not any(
domain in e.lower()
for domain in ["example.com", "test.com", "email.com"]
)
]
if company_emails:
info["email"] = company_emails[0]
return info
except Exception as e:
logger.warning(f"Failed to scrape {url}: {e}")
return {
"markdown": None,
"images": [],
"logo_url": None,
"email": None,
"title": None,
"description": None,
"error": str(e),
}
def batch_scrape(
self, urls: List[str], batch_size: int = 5
) -> Dict[str, FirecrawlResult]:
"""
Scrape multiple URLs in batches.
Args:
urls: List of URLs to scrape
batch_size: Number of concurrent scrapes
Returns:
Dictionary mapping URLs to results
"""
results = {}
for i in range(0, len(urls), batch_size):
batch = urls[i : i + batch_size]
logger.info(f"Scraping batch {i // batch_size + 1} ({len(batch)} URLs)")
for url in batch:
try:
results[url] = self.scrape_url(url)
except Exception as e:
logger.error(f"Failed to scrape {url}: {e}")
results[url] = FirecrawlResult(
markdown=None,
metadata={"error": str(e)},
images=[],
)
return results
+359
View File
@@ -0,0 +1,359 @@
"""
Claude Haiku service for content generation.
"""
import json
import logging
from typing import Optional, List, Dict, Any
from anthropic import Anthropic
from ..config import get_settings
from ..models.schemas import HaikuTagsResponse, HaikuContentResponse, TagCategory
logger = logging.getLogger(__name__)
class HaikuService:
"""Service for AI content generation using Claude Haiku."""
def __init__(self):
"""Initialize Anthropic client."""
settings = get_settings()
self.client = Anthropic(api_key=settings.anthropic_api_key)
self.model = "claude-3-5-haiku-20241022"
def _call_haiku(
self,
system_prompt: str,
user_prompt: str,
max_tokens: int = 4096,
) -> str:
"""
Make a call to Claude Haiku.
Args:
system_prompt: System instructions
user_prompt: User message
max_tokens: Maximum response tokens
Returns:
Response text
"""
response = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
return response.content[0].text
def extract_tags(
self,
company_name: str,
website_markdown: Optional[str],
address: Optional[str] = None,
) -> HaikuTagsResponse:
"""
Extract tags from company information.
Args:
company_name: Company name
website_markdown: Scraped website content
address: Company address
Returns:
Extracted tags with confidence
"""
system_prompt = """You are an expert at analyzing Odoo partner companies.
Extract relevant tags from the provided company information.
Respond with valid JSON only, no other text. Use this exact structure:
{
"services": ["list of services they offer"],
"industries": ["list of industries they serve"],
"modules": ["list of Odoo modules they specialize in"],
"partner_level": "Ready Partner|Silver Partner|Gold Partner|Platinum Partner or null",
"confidence": 0.0 to 1.0
}
Valid services: Implementation, Customization, Training, Support, Migration, Integration, Consulting, Hosting, Development
Valid industries: Manufacturing, Retail, E-commerce, Healthcare, Education, Finance, Logistics, Construction, Food & Beverage, Professional Services
Valid modules: Sales, CRM, Inventory, Accounting, Manufacturing, Website, E-commerce, HR, Project, Purchase, Point of Sale
Only include tags you can confidently identify from the content."""
user_prompt = f"""Company: {company_name}
Address: {address or "Not provided"}
Website content:
{website_markdown[:8000] if website_markdown else "No website content available"}
Extract tags for this Odoo partner company."""
try:
response = self._call_haiku(system_prompt, user_prompt, max_tokens=1024)
# Parse JSON response
data = json.loads(response)
return HaikuTagsResponse(**data)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse tags response: {e}")
return HaikuTagsResponse(confidence=0.0)
except Exception as e:
logger.error(f"Tag extraction failed: {e}")
return HaikuTagsResponse(confidence=0.0)
def generate_company_content(
self,
company_name: str,
city_name: str,
country_name: str,
website_markdown: Optional[str],
services: List[str],
industries: List[str],
modules: List[str],
partner_level: Optional[str] = None,
language: str = "de",
) -> HaikuContentResponse:
"""
Generate SEO-optimized company page content.
Args:
company_name: Company name
city_name: City name
country_name: Country name
website_markdown: Scraped website content
services: List of service tags
industries: List of industry tags
modules: List of module tags
partner_level: Partner certification level
language: Output language code
Returns:
Generated content
"""
lang_instructions = {
"de": "Write all content in German (Deutsch).",
"en": "Write all content in English.",
"es": "Write all content in Spanish (Español).",
"fr": "Write all content in French (Français).",
"pt": "Write all content in Portuguese (Português).",
"ar": "Write all content in Arabic (العربية).",
}
system_prompt = f"""You are an expert SEO content writer for Odoo partner directories.
{lang_instructions.get(language, lang_instructions["en"])}
Create engaging, informative content about an Odoo partner company.
The content should be at least 800 words and SEO-optimized.
Respond with valid JSON only:
{{
"description": "2-3 sentence company description",
"content_html": "Full HTML content with headings, paragraphs, lists",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "SEO meta description (150-160 chars)"
}}
Include:
- Company overview
- Services offered
- Industries served
- Odoo modules expertise
- Why choose this partner
- Location information
Use semantic HTML: <h2>, <h3>, <p>, <ul>, <li>, <strong>"""
tags_info = f"""
Services: {", ".join(services) if services else "Various Odoo services"}
Industries: {", ".join(industries) if industries else "Multiple industries"}
Modules: {", ".join(modules) if modules else "Various Odoo modules"}
Partner Level: {partner_level or "Odoo Partner"}"""
user_prompt = f"""Create content for:
Company: {company_name}
Location: {city_name}, {country_name}
{tags_info}
Website information:
{website_markdown[:6000] if website_markdown else "Limited information available"}
Generate comprehensive SEO content for this Odoo partner's directory page."""
try:
response = self._call_haiku(system_prompt, user_prompt, max_tokens=4096)
data = json.loads(response)
return HaikuContentResponse(**data)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse content response: {e}")
# Return fallback content
return self._generate_fallback_content(
company_name, city_name, country_name, services, language
)
except Exception as e:
logger.error(f"Content generation failed: {e}")
return self._generate_fallback_content(
company_name, city_name, country_name, services, language
)
def _generate_fallback_content(
self,
company_name: str,
city_name: str,
country_name: str,
services: List[str],
language: str,
) -> HaikuContentResponse:
"""Generate basic fallback content when AI generation fails."""
services_text = ", ".join(services) if services else "Odoo implementation"
if language == "de":
description = f"{company_name} ist ein Odoo Partner in {city_name}, {country_name}."
content = f"""<h2>Über {company_name}</h2>
<p>{company_name} ist ein zertifizierter Odoo Partner mit Sitz in {city_name}.
Das Unternehmen bietet professionelle Odoo-Dienstleistungen für Unternehmen in der Region.</p>
<h3>Dienstleistungen</h3>
<p>Als Odoo Partner bietet {company_name} folgende Leistungen: {services_text}.</p>
<h3>Standort</h3>
<p>{company_name} befindet sich in {city_name}, {country_name} und betreut Kunden in der gesamten Region.</p>"""
meta_title = f"{company_name} - Odoo Partner in {city_name}"
meta_desc = f"{company_name} ist Ihr Odoo Partner in {city_name}. Professionelle ERP-Beratung und Implementierung."
else:
description = f"{company_name} is an Odoo partner located in {city_name}, {country_name}."
content = f"""<h2>About {company_name}</h2>
<p>{company_name} is a certified Odoo partner based in {city_name}.
The company provides professional Odoo services for businesses in the region.</p>
<h3>Services</h3>
<p>As an Odoo partner, {company_name} offers: {services_text}.</p>
<h3>Location</h3>
<p>{company_name} is located in {city_name}, {country_name} and serves clients throughout the region.</p>"""
meta_title = f"{company_name} - Odoo Partner in {city_name}"
meta_desc = f"{company_name} is your Odoo partner in {city_name}. Professional ERP consulting and implementation."
return HaikuContentResponse(
description=description,
content_html=content,
meta_title=meta_title[:60],
meta_description=meta_desc[:160],
)
def generate_city_hub_content(
self,
city_name: str,
country_name: str,
company_count: int,
top_services: List[str],
language: str = "de",
) -> Dict[str, str]:
"""
Generate city hub page content.
Args:
city_name: City name
country_name: Country name
company_count: Number of companies in city
top_services: Most common services
language: Output language
Returns:
Dictionary with content sections
"""
lang_instructions = {
"de": "Write in German.",
"en": "Write in English.",
}
system_prompt = f"""You are an SEO expert writing city hub pages for an Odoo partner directory.
{lang_instructions.get(language, "Write in English.")}
Create compelling content (1000+ words) about finding Odoo partners in this city.
Return valid JSON:
{{
"intro": "Introduction paragraph about Odoo partners in this city",
"content_html": "Full HTML content about Odoo services in this city",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "Meta description (150-160 chars)"
}}"""
user_prompt = f"""City: {city_name}, {country_name}
Number of Odoo Partners: {company_count}
Top Services: {", ".join(top_services)}
Create engaging city hub content for this Odoo partner directory."""
try:
response = self._call_haiku(system_prompt, user_prompt)
return json.loads(response)
except Exception as e:
logger.error(f"City hub content generation failed: {e}")
return {
"intro": f"Find Odoo partners in {city_name}.",
"content_html": f"<p>Discover {company_count} Odoo partners in {city_name}, {country_name}.</p>",
"meta_title": f"Odoo Partners in {city_name}",
"meta_description": f"Find {company_count} certified Odoo partners in {city_name}, {country_name}.",
}
def generate_country_hub_content(
self,
country_name: str,
city_count: int,
company_count: int,
top_cities: List[str],
language: str = "de",
) -> Dict[str, str]:
"""
Generate country hub page content.
Args:
country_name: Country name
city_count: Number of cities with partners
company_count: Total companies in country
top_cities: Cities with most partners
language: Output language
Returns:
Dictionary with content sections
"""
lang_instructions = {
"de": "Write in German.",
"en": "Write in English.",
}
system_prompt = f"""You are an SEO expert writing country hub pages for an Odoo partner directory.
{lang_instructions.get(language, "Write in English.")}
Create comprehensive content (1200+ words) about Odoo partners in this country.
Return valid JSON:
{{
"intro": "Introduction about Odoo partners in this country",
"content_html": "Full HTML content about the Odoo ecosystem in this country",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "Meta description (150-160 chars)"
}}"""
user_prompt = f"""Country: {country_name}
Number of Cities: {city_count}
Total Odoo Partners: {company_count}
Top Cities: {", ".join(top_cities)}
Create engaging country hub content for this Odoo partner directory."""
try:
response = self._call_haiku(system_prompt, user_prompt)
return json.loads(response)
except Exception as e:
logger.error(f"Country hub content generation failed: {e}")
return {
"intro": f"Find Odoo partners across {country_name}.",
"content_html": f"<p>Discover {company_count} Odoo partners in {city_count} cities across {country_name}.</p>",
"meta_title": f"Odoo Partners in {country_name}",
"meta_description": f"Find {company_count} certified Odoo partners in {city_count} cities across {country_name}.",
}
+223
View File
@@ -0,0 +1,223 @@
"""
Image processing service for company logos and images.
"""
import logging
from pathlib import Path
from typing import Optional, Tuple
from urllib.parse import urlparse
import hashlib
import httpx
from PIL import Image
import io
from ..config import get_settings
logger = logging.getLogger(__name__)
class ImageService:
"""Service for downloading and processing images."""
# Standard sizes for company images
SIZES = {
"thumbnail": (150, 150),
"medium": (300, 300),
"large": (600, 600),
}
def __init__(self):
"""Initialize image service."""
settings = get_settings()
self.images_dir = settings.images_dir
self.images_dir.mkdir(parents=True, exist_ok=True)
def _get_image_hash(self, url: str) -> str:
"""Generate a short hash for an image URL."""
return hashlib.md5(url.encode()).hexdigest()[:12]
def _get_image_path(
self, company_slug: str, image_type: str = "logo", size: str = "medium"
) -> Path:
"""
Get the path for storing a company image.
Args:
company_slug: Company slug for filename
image_type: Type of image (logo, photo)
size: Size variant (thumbnail, medium, large)
Returns:
Path to image file
"""
filename = f"{company_slug}_{image_type}_{size}.webp"
return self.images_dir / filename
def download_image(self, url: str) -> Optional[bytes]:
"""
Download an image from URL.
Args:
url: Image URL
Returns:
Image bytes or None if download failed
"""
try:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if not content_type.startswith("image/"):
logger.warning(f"URL is not an image: {url}")
return None
return response.content
except Exception as e:
logger.error(f"Failed to download image from {url}: {e}")
return None
def process_image(
self,
image_data: bytes,
size: Tuple[int, int],
quality: int = 85,
) -> Optional[bytes]:
"""
Process and convert an image to WebP format.
Args:
image_data: Raw image bytes
size: Target size (width, height)
quality: WebP quality (1-100)
Returns:
Processed image bytes or None if processing failed
"""
try:
# Open image
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (for WebP)
if img.mode in ("RGBA", "P"):
# Create white background for transparency
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize maintaining aspect ratio
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to WebP
output = io.BytesIO()
img.save(output, format="WEBP", quality=quality, method=6)
return output.getvalue()
except Exception as e:
logger.error(f"Failed to process image: {e}")
return None
def save_company_logo(
self,
company_slug: str,
logo_url: str,
sizes: Optional[list] = None,
) -> dict:
"""
Download and save company logo in multiple sizes.
Args:
company_slug: Company slug for filename
logo_url: URL of the logo image
sizes: List of size names to generate (default: all)
Returns:
Dictionary mapping size names to file paths
"""
if sizes is None:
sizes = list(self.SIZES.keys())
saved_paths = {}
# Download original
image_data = self.download_image(logo_url)
if not image_data:
return saved_paths
# Process and save each size
for size_name in sizes:
if size_name not in self.SIZES:
continue
size = self.SIZES[size_name]
processed = self.process_image(image_data, size)
if processed:
path = self._get_image_path(company_slug, "logo", size_name)
path.write_bytes(processed)
saved_paths[size_name] = str(path)
logger.debug(f"Saved {size_name} logo: {path}")
return saved_paths
def get_logo_urls(self, company_slug: str, base_url: str = "") -> dict:
"""
Get URLs for company logo images.
Args:
company_slug: Company slug
base_url: Base URL for the images (e.g., /static/images/companies/)
Returns:
Dictionary mapping size names to URLs
"""
urls = {}
for size_name in self.SIZES:
path = self._get_image_path(company_slug, "logo", size_name)
if path.exists():
relative_path = path.relative_to(self.images_dir)
urls[size_name] = f"{base_url}{relative_path}"
return urls
def cleanup_company_images(self, company_slug: str) -> int:
"""
Remove all images for a company.
Args:
company_slug: Company slug
Returns:
Number of files removed
"""
removed = 0
for file in self.images_dir.glob(f"{company_slug}_*"):
file.unlink()
removed += 1
return removed
def get_storage_stats(self) -> dict:
"""
Get storage statistics for the images directory.
Returns:
Dictionary with stats
"""
total_size = 0
file_count = 0
for file in self.images_dir.glob("*.webp"):
total_size += file.stat().st_size
file_count += 1
return {
"file_count": file_count,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": str(self.images_dir),
}
+472
View File
@@ -0,0 +1,472 @@
"""
Supabase database service for Odoo Directory.
Handles all database operations.
"""
import logging
from typing import Optional, List, Dict, Any
from uuid import UUID
from supabase import create_client, Client
from ..config import get_settings
from ..models.schemas import (
Region,
Country,
City,
Tag,
Company,
CompanyTag,
CityTag,
CountryTag,
ExecutionPlan,
PlanStep,
TagCategory,
StepStatus,
)
logger = logging.getLogger(__name__)
class SupabaseService:
"""Service for Supabase database operations."""
def __init__(self):
"""Initialize Supabase client."""
settings = get_settings()
self.client: Client = create_client(
settings.supabase_url, settings.supabase_key
)
# ==================== Region Operations ====================
def create_region(self, region: Region) -> Region:
"""Create a new region."""
data = {"name": region.name, "slug": region.slug}
result = self.client.table("regions").insert(data).execute()
return Region(**result.data[0])
def get_region_by_slug(self, slug: str) -> Optional[Region]:
"""Get region by slug."""
result = (
self.client.table("regions").select("*").eq("slug", slug).execute()
)
if result.data:
return Region(**result.data[0])
return None
def list_regions(self) -> List[Region]:
"""List all regions."""
result = self.client.table("regions").select("*").execute()
return [Region(**r) for r in result.data]
# ==================== Country Operations ====================
def create_country(self, country: Country) -> Country:
"""Create a new country."""
data = {
"region_id": str(country.region_id) if country.region_id else None,
"name": country.name,
"name_local": country.name_local,
"code": country.code,
"slug": country.slug,
"language": country.language,
}
result = self.client.table("countries").insert(data).execute()
return Country(**result.data[0])
def get_country_by_code(self, code: str) -> Optional[Country]:
"""Get country by ISO code."""
result = (
self.client.table("countries")
.select("*")
.eq("code", code.upper())
.execute()
)
if result.data:
return Country(**result.data[0])
return None
def get_country_by_slug(self, slug: str) -> Optional[Country]:
"""Get country by slug."""
result = (
self.client.table("countries").select("*").eq("slug", slug).execute()
)
if result.data:
return Country(**result.data[0])
return None
def list_countries(self, region_id: Optional[UUID] = None) -> List[Country]:
"""List countries, optionally filtered by region."""
query = self.client.table("countries").select("*")
if region_id:
query = query.eq("region_id", str(region_id))
result = query.execute()
return [Country(**c) for c in result.data]
# ==================== City Operations ====================
def create_city(self, city: City) -> City:
"""Create a new city."""
data = {
"country_id": str(city.country_id) if city.country_id else None,
"name": city.name,
"name_local": city.name_local,
"slug": city.slug,
"state": city.state,
"population": city.population,
"latitude": city.latitude,
"longitude": city.longitude,
}
result = self.client.table("cities").insert(data).execute()
return City(**result.data[0])
def get_city_by_slug(
self, slug: str, country_id: Optional[UUID] = None
) -> Optional[City]:
"""Get city by slug, optionally filtered by country."""
query = self.client.table("cities").select("*").eq("slug", slug)
if country_id:
query = query.eq("country_id", str(country_id))
result = query.execute()
if result.data:
return City(**result.data[0])
return None
def list_cities(self, country_id: Optional[UUID] = None) -> List[City]:
"""List cities, optionally filtered by country."""
query = self.client.table("cities").select("*")
if country_id:
query = query.eq("country_id", str(country_id))
result = query.execute()
return [City(**c) for c in result.data]
# ==================== Tag Operations ====================
def create_tag(self, tag: Tag) -> Tag:
"""Create a new tag."""
data = {
"category": tag.category.value,
"name": tag.name,
"slug": tag.slug,
"description": tag.description,
}
result = self.client.table("tags").insert(data).execute()
return Tag(**result.data[0])
def get_tag_by_slug(self, slug: str) -> Optional[Tag]:
"""Get tag by slug."""
result = self.client.table("tags").select("*").eq("slug", slug).execute()
if result.data:
return Tag(**result.data[0])
return None
def list_tags(self, category: Optional[TagCategory] = None) -> List[Tag]:
"""List tags, optionally filtered by category."""
query = self.client.table("tags").select("*")
if category:
query = query.eq("category", category.value)
result = query.execute()
return [Tag(**t) for t in result.data]
def get_or_create_tag(
self, name: str, category: TagCategory, description: Optional[str] = None
) -> Tag:
"""Get existing tag or create new one."""
slug = name.lower().replace(" ", "-").replace("&", "and")
existing = self.get_tag_by_slug(slug)
if existing:
return existing
return self.create_tag(
Tag(category=category, name=name, slug=slug, description=description)
)
# ==================== Company Operations ====================
def create_company(self, company: Company) -> Company:
"""Create a new company."""
data = {
"city_id": str(company.city_id) if company.city_id else None,
"name": company.name,
"slug": company.slug,
"google_place_id": company.google_place_id,
"address": company.address,
"phone": company.phone,
"website": company.website,
"email": company.email,
"rating": company.rating,
"review_count": company.review_count,
"latitude": company.latitude,
"longitude": company.longitude,
"status": company.status,
}
result = self.client.table("companies").insert(data).execute()
return Company(**result.data[0])
def update_company(self, company_id: UUID, updates: Dict[str, Any]) -> Company:
"""Update a company."""
result = (
self.client.table("companies")
.update(updates)
.eq("id", str(company_id))
.execute()
)
return Company(**result.data[0])
def get_company_by_id(self, company_id: UUID) -> Optional[Company]:
"""Get company by ID."""
result = (
self.client.table("companies")
.select("*")
.eq("id", str(company_id))
.execute()
)
if result.data:
return Company(**result.data[0])
return None
def get_company_by_place_id(self, place_id: str) -> Optional[Company]:
"""Get company by Google Place ID (for deduplication)."""
result = (
self.client.table("companies")
.select("*")
.eq("google_place_id", place_id)
.execute()
)
if result.data:
return Company(**result.data[0])
return None
def list_companies(
self,
city_id: Optional[UUID] = None,
status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Company]:
"""List companies with optional filters."""
query = self.client.table("companies").select("*")
if city_id:
query = query.eq("city_id", str(city_id))
if status:
query = query.eq("status", status)
query = query.range(offset, offset + limit - 1)
result = query.execute()
return [Company(**c) for c in result.data]
def count_companies(
self, city_id: Optional[UUID] = None, status: Optional[str] = None
) -> int:
"""Count companies with optional filters."""
query = self.client.table("companies").select("id", count="exact")
if city_id:
query = query.eq("city_id", str(city_id))
if status:
query = query.eq("status", status)
result = query.execute()
return result.count or 0
# ==================== Company Tag Operations ====================
def add_company_tag(
self, company_id: UUID, tag_id: UUID, confidence: float = 1.0
) -> CompanyTag:
"""Add a tag to a company."""
data = {
"company_id": str(company_id),
"tag_id": str(tag_id),
"confidence": confidence,
}
result = self.client.table("company_tags").insert(data).execute()
return CompanyTag(**result.data[0])
def get_company_tags(self, company_id: UUID) -> List[Tag]:
"""Get all tags for a company."""
result = (
self.client.table("company_tags")
.select("tag_id, tags(*)")
.eq("company_id", str(company_id))
.execute()
)
tags = []
for item in result.data:
if item.get("tags"):
tags.append(Tag(**item["tags"]))
return tags
def remove_company_tags(self, company_id: UUID) -> None:
"""Remove all tags from a company."""
self.client.table("company_tags").delete().eq(
"company_id", str(company_id)
).execute()
# ==================== City Tag Aggregation ====================
def upsert_city_tag(self, city_id: UUID, tag_id: UUID, count: int) -> CityTag:
"""Upsert city tag aggregation."""
data = {
"city_id": str(city_id),
"tag_id": str(tag_id),
"company_count": count,
}
result = (
self.client.table("city_tags")
.upsert(data, on_conflict="city_id,tag_id")
.execute()
)
return CityTag(**result.data[0])
def get_city_tags(self, city_id: UUID) -> List[Dict[str, Any]]:
"""Get all tag aggregations for a city with tag details."""
result = (
self.client.table("city_tags")
.select("*, tags(*)")
.eq("city_id", str(city_id))
.execute()
)
return result.data
# ==================== Country Tag Aggregation ====================
def upsert_country_tag(
self, country_id: UUID, tag_id: UUID, company_count: int, city_count: int
) -> CountryTag:
"""Upsert country tag aggregation."""
data = {
"country_id": str(country_id),
"tag_id": str(tag_id),
"company_count": company_count,
"city_count": city_count,
}
result = (
self.client.table("country_tags")
.upsert(data, on_conflict="country_id,tag_id")
.execute()
)
return CountryTag(**result.data[0])
def get_country_tags(self, country_id: UUID) -> List[Dict[str, Any]]:
"""Get all tag aggregations for a country with tag details."""
result = (
self.client.table("country_tags")
.select("*, tags(*)")
.eq("country_id", str(country_id))
.execute()
)
return result.data
# ==================== Execution Plan Operations ====================
def create_execution_plan(self, plan: ExecutionPlan) -> ExecutionPlan:
"""Create a new execution plan."""
data = {
"name": plan.name,
"country_code": plan.country_code,
"cities": plan.cities,
"status": plan.status.value,
}
result = self.client.table("execution_plans").insert(data).execute()
return ExecutionPlan(**result.data[0])
def update_execution_plan(
self, plan_id: UUID, updates: Dict[str, Any]
) -> ExecutionPlan:
"""Update an execution plan."""
result = (
self.client.table("execution_plans")
.update(updates)
.eq("id", str(plan_id))
.execute()
)
return ExecutionPlan(**result.data[0])
def get_execution_plan(self, plan_id: UUID) -> Optional[ExecutionPlan]:
"""Get execution plan by ID."""
result = (
self.client.table("execution_plans")
.select("*")
.eq("id", str(plan_id))
.execute()
)
if result.data:
plan = ExecutionPlan(**result.data[0])
# Load steps
steps_result = (
self.client.table("plan_steps")
.select("*")
.eq("plan_id", str(plan_id))
.execute()
)
plan.steps = [PlanStep(**s) for s in steps_result.data]
return plan
return None
def list_execution_plans(
self, status: Optional[StepStatus] = None
) -> List[ExecutionPlan]:
"""List execution plans."""
query = self.client.table("execution_plans").select("*")
if status:
query = query.eq("status", status.value)
result = query.execute()
return [ExecutionPlan(**p) for p in result.data]
# ==================== Plan Step Operations ====================
def create_plan_step(self, step: PlanStep) -> PlanStep:
"""Create a new plan step."""
data = {
"plan_id": str(step.plan_id) if step.plan_id else None,
"step_name": step.step_name,
"city_id": str(step.city_id) if step.city_id else None,
"status": step.status.value,
"items_processed": step.items_processed,
"items_total": step.items_total,
}
result = self.client.table("plan_steps").insert(data).execute()
return PlanStep(**result.data[0])
def update_plan_step(self, step_id: UUID, updates: Dict[str, Any]) -> PlanStep:
"""Update a plan step."""
result = (
self.client.table("plan_steps")
.update(updates)
.eq("id", str(step_id))
.execute()
)
return PlanStep(**result.data[0])
def get_plan_steps(
self, plan_id: UUID, step_name: Optional[str] = None
) -> List[PlanStep]:
"""Get steps for a plan."""
query = (
self.client.table("plan_steps").select("*").eq("plan_id", str(plan_id))
)
if step_name:
query = query.eq("step_name", step_name)
result = query.execute()
return [PlanStep(**s) for s in result.data]
# ==================== Error Logging ====================
def log_error(
self,
entity_type: str,
entity_id: Optional[UUID],
operation: str,
error_message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
"""Log an error to the database."""
data = {
"entity_type": entity_type,
"entity_id": str(entity_id) if entity_id else None,
"operation": operation,
"error_message": error_message,
"details": details or {},
}
try:
self.client.table("error_logs").insert(data).execute()
except Exception as e:
logger.error(f"Failed to log error to database: {e}")
+309
View File
@@ -0,0 +1,309 @@
"""
Tag aggregation service for cities and countries.
"""
import logging
from typing import Dict, List, Any
from uuid import UUID
from collections import defaultdict
from .supabase import SupabaseService
from ..models.schemas import TagCategory
logger = logging.getLogger(__name__)
class TagAggregationService:
"""Service for aggregating tags across cities and countries."""
def __init__(self, db: SupabaseService = None):
"""
Initialize tag aggregation service.
Args:
db: Supabase service instance (creates new one if not provided)
"""
self.db = db or SupabaseService()
def aggregate_city_tags(self, city_id: UUID) -> Dict[str, int]:
"""
Aggregate all company tags for a city.
Counts how many companies have each tag and stores in city_tags table.
Args:
city_id: City UUID
Returns:
Dictionary mapping tag slugs to company counts
"""
logger.info(f"Aggregating tags for city {city_id}")
# Get all companies in the city
companies = self.db.list_companies(city_id=city_id, limit=1000)
# Count tags across all companies
tag_counts = defaultdict(int)
tag_id_map = {}
for company in companies:
company_tags = self.db.get_company_tags(company.id)
for tag in company_tags:
tag_counts[tag.slug] += 1
tag_id_map[tag.slug] = tag.id
# Update city_tags table
for slug, count in tag_counts.items():
tag_id = tag_id_map[slug]
self.db.upsert_city_tag(city_id, tag_id, count)
logger.info(f"Aggregated {len(tag_counts)} tags for city {city_id}")
return dict(tag_counts)
def aggregate_country_tags(self, country_id: UUID) -> Dict[str, Dict[str, int]]:
"""
Aggregate all city tags for a country.
Rolls up city-level tag counts to country level.
Args:
country_id: Country UUID
Returns:
Dictionary mapping tag slugs to {company_count, city_count}
"""
logger.info(f"Aggregating tags for country {country_id}")
# Get all cities in the country
cities = self.db.list_cities(country_id=country_id)
# Aggregate across all cities
tag_stats = defaultdict(lambda: {"company_count": 0, "city_count": 0})
tag_id_map = {}
for city in cities:
city_tags = self.db.get_city_tags(city.id)
for ct in city_tags:
tag_data = ct.get("tags", {})
tag_slug = tag_data.get("slug")
tag_id = tag_data.get("id")
if tag_slug and tag_id:
company_count = ct.get("company_count", 0)
tag_stats[tag_slug]["company_count"] += company_count
tag_stats[tag_slug]["city_count"] += 1
tag_id_map[tag_slug] = tag_id
# Update country_tags table
for slug, stats in tag_stats.items():
tag_id = tag_id_map[slug]
self.db.upsert_country_tag(
country_id, tag_id, stats["company_count"], stats["city_count"]
)
logger.info(f"Aggregated {len(tag_stats)} tags for country {country_id}")
return dict(tag_stats)
def get_city_tags_by_category(
self, city_id: UUID
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get city tags organized by category.
Args:
city_id: City UUID
Returns:
Dictionary with categories as keys and lists of tag info
"""
city_tags = self.db.get_city_tags(city_id)
result = {
"services": [],
"industries": [],
"modules": [],
"partner_levels": [],
}
for ct in city_tags:
tag_data = ct.get("tags", {})
category = tag_data.get("category")
company_count = ct.get("company_count", 0)
tag_info = {
"name": tag_data.get("name"),
"slug": tag_data.get("slug"),
"count": company_count,
}
if category == "service":
result["services"].append(tag_info)
elif category == "industry":
result["industries"].append(tag_info)
elif category == "module":
result["modules"].append(tag_info)
elif category == "partner_level":
result["partner_levels"].append(tag_info)
# Sort each category by count (descending)
for key in result:
result[key].sort(key=lambda x: x["count"], reverse=True)
return result
def get_country_tags_by_category(
self, country_id: UUID
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get country tags organized by category.
Args:
country_id: Country UUID
Returns:
Dictionary with categories as keys and lists of tag info
"""
country_tags = self.db.get_country_tags(country_id)
result = {
"services": [],
"industries": [],
"modules": [],
"partner_levels": [],
}
for ct in country_tags:
tag_data = ct.get("tags", {})
category = tag_data.get("category")
tag_info = {
"name": tag_data.get("name"),
"slug": tag_data.get("slug"),
"company_count": ct.get("company_count", 0),
"city_count": ct.get("city_count", 0),
}
if category == "service":
result["services"].append(tag_info)
elif category == "industry":
result["industries"].append(tag_info)
elif category == "module":
result["modules"].append(tag_info)
elif category == "partner_level":
result["partner_levels"].append(tag_info)
# Sort each category by company count (descending)
for key in result:
result[key].sort(key=lambda x: x["company_count"], reverse=True)
return result
def export_city_tags_json(self, city_id: UUID) -> Dict[str, Any]:
"""
Export city tags as JSON for frontend filtering.
Args:
city_id: City UUID
Returns:
JSON-serializable dictionary
"""
tags_by_category = self.get_city_tags_by_category(city_id)
return {
"filters": {
"services": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["services"]
],
"industries": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["industries"]
],
"modules": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["modules"]
],
"partner_levels": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["partner_levels"]
],
}
}
def export_country_tags_json(self, country_id: UUID) -> Dict[str, Any]:
"""
Export country tags as JSON for frontend filtering.
Args:
country_id: Country UUID
Returns:
JSON-serializable dictionary
"""
tags_by_category = self.get_country_tags_by_category(country_id)
return {
"filters": {
"services": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["services"]
],
"industries": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["industries"]
],
"modules": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["modules"]
],
"partner_levels": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["partner_levels"]
],
}
}
def refresh_all_aggregations(self, country_id: UUID) -> None:
"""
Refresh all tag aggregations for a country.
First aggregates all city tags, then rolls up to country level.
Args:
country_id: Country UUID
"""
logger.info(f"Refreshing all aggregations for country {country_id}")
# Get all cities
cities = self.db.list_cities(country_id=country_id)
# Aggregate each city
for city in cities:
self.aggregate_city_tags(city.id)
# Aggregate country
self.aggregate_country_tags(country_id)
logger.info(f"Completed aggregation refresh for country {country_id}")