"""Master Flow Recommender
--------------------------
Gemini = System Architect, Not Worker
Responsibilities:
- Decides optimal crawler/scraper combination
- Sets strictness level
- Defines fallback plan
- Enforces stopping rules
Does NOT:
- Scrape pages
- Parse HTML
- Extract products
- Click links
Architecture:
1. Preflight Probe (cheap heuristics) β Site Signal Snapshot
2. Gemini Flow Recommender (strategy) β Execution Contract
3. Orchestrator (execution) β Results
"""
import os
import re
import json
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, asdict
from urllib.parse import urlparse, urljoin
import requests
import google.generativeai as genai
# Load environment variables from .env file (LOCAL ONLY)
# HuggingFace Spaces: Environment variables are injected from repository secrets automatically
# Local Development: Load from .env file if it exists
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # dotenv not installed, will use system environment variables (HF Spaces compatible)
@dataclass
class SiteSignals:
"""Lightweight metadata about a site with essential content."""
url: str
homepage_signals: Dict
url_patterns: List[str]
js_dependency: bool
forms_detected: bool
html_density: str
keywords_found: List[str]
# Content signals for Gemini
page_title: str
meta_description: str
sample_text: str # First 500 chars of visible text
site_category_hints: List[str] # e.g., ['sauna', 'wellness', 'construction']
navbar_links: List[Dict] # Navigation menu structure - richest signal
def to_dict(self) -> Dict:
return asdict(self)
@dataclass
class FlowRecommendation:
"""Execution contract for the orchestrator."""
crawler: str # 'web' | 'ai' | 'unified'
scraper: str # 'static' | 'lam' | 'ai' | 'auto'
strictness: str # 'lenient' | 'balanced' | 'strict'
reasoning: Dict
fallback_plan: List[Dict]
exploration_config: Dict
def to_dict(self) -> Dict:
return asdict(self)
class PreflightProbe:
"""Cheap heuristic analysis of a site (NO Gemini)."""
def __init__(self, timeout: int = 10):
"""
Initialize preflight probe.
Args:
timeout: Request timeout in seconds
"""
self.timeout = timeout
self.jina_available = bool(os.getenv('JINA_API_KEY'))
def probe(self, url: str) -> SiteSignals:
"""
Analyze site with cheap heuristics.
Args:
url: Base URL to probe
Returns:
Site signal snapshot
"""
print(f"\nπ Preflight Probe: {url}")
try:
# Fetch homepage
html = self._fetch_homepage(url)
# Extract signals
homepage_signals = self._analyze_links(html, url)
url_patterns = self._extract_url_patterns(html)
js_dependency = self._detect_js_dependency(html)
forms_detected = self._detect_forms(html)
html_density = self._estimate_html_density(html)
keywords_found = self._find_keywords(html)
# Extract content signals for Gemini
page_title = self._extract_title(html)
meta_description = self._extract_meta_description(html)
sample_text = self._extract_sample_text(html)
site_category_hints = self._detect_site_category(html, url)
navbar_links = self._extract_navbar_links(html, url)
signals = SiteSignals(
url=url,
homepage_signals=homepage_signals,
url_patterns=url_patterns,
js_dependency=js_dependency,
forms_detected=forms_detected,
html_density=html_density,
keywords_found=keywords_found,
page_title=page_title,
meta_description=meta_description,
sample_text=sample_text,
site_category_hints=site_category_hints,
navbar_links=navbar_links
)
print(f" β Signals captured:")
print(f" Title: {page_title[:60]}..." if len(page_title) > 60 else f" Title: {page_title}")
print(f" Navbar Links: {len(navbar_links)} ({', '.join([link['text'] for link in navbar_links[:5]])}...)")
print(f" Links: {homepage_signals['link_count']}")
print(f" URL patterns: {len(url_patterns)}")
print(f" JS dependency: {js_dependency}")
print(f" Forms: {forms_detected}")
print(f" Category hints: {', '.join(site_category_hints[:3])}")
print(f" Keywords: {', '.join(keywords_found[:5])}")
return signals
except Exception as e:
print(f" β Probe failed: {e}")
# Return minimal signals
return SiteSignals(
url=url,
homepage_signals={'link_count': 0, 'product_like_links': 0, 'project_like_links': 0, 'blog_like_links': 0},
url_patterns=[],
js_dependency=False,
forms_detected=False,
html_density='unknown',
keywords_found=[],
page_title='',
meta_description='',
sample_text='',
site_category_hints=[],
navbar_links=[]
)
def _fetch_homepage(self, url: str) -> str:
"""Fetch homepage HTML with retry logic."""
if self.jina_available:
# Use Jina for better rendering with retry logic
retry_delays = [20, 40, 60]
for attempt in range(3):
try:
jina_url = f"https://r.jina.ai/{url}"
headers = {'Authorization': f'Bearer {os.getenv("JINA_API_KEY")}'}
response = requests.get(jina_url, headers=headers, timeout=self.timeout)
response.raise_for_status()
return response.text
except Exception as e:
error_str = str(e)
if "429" in error_str or "rate limit" in error_str.lower() or "timeout" in error_str.lower():
if attempt < 2:
retry_delay = retry_delays[attempt]
print(f" β οΈ Jina error fetching homepage - retrying in {retry_delay}s (attempt {attempt + 1}/3)")
time.sleep(retry_delay)
else:
print(f" β Failed to fetch homepage via Jina after all retries")
print(f" βΈοΈ Waiting 60s before continuing...")
time.sleep(60)
# Fallback to direct request
try:
response = requests.get(url, timeout=self.timeout)
return response.text
except:
raise ValueError(f"Failed to fetch homepage: {url}")
else:
raise
else:
# Fallback to direct request
response = requests.get(url, timeout=self.timeout)
return response.text
def _analyze_links(self, html: str, base_url: str) -> Dict:
"""Analyze link patterns in HTML."""
# Extract all href attributes
href_pattern = r'href=["\']([^"\']+)["\']'
links = re.findall(href_pattern, html, re.IGNORECASE)
# Classify links
product_keywords = ['product', 'item', 'shop', 'catalog', 'store', 'buy', 'model']
project_keywords = ['project', 'case-study', 'portfolio', 'work', 'gallery', 'showcase']
blog_keywords = ['blog', 'news', 'article', 'post', 'story']
product_like = 0
project_like = 0
blog_like = 0
for link in links:
link_lower = link.lower()
if any(kw in link_lower for kw in product_keywords):
product_like += 1
if any(kw in link_lower for kw in project_keywords):
project_like += 1
if any(kw in link_lower for kw in blog_keywords):
blog_like += 1
return {
'link_count': len(links),
'product_like_links': product_like,
'project_like_links': project_like,
'blog_like_links': blog_like
}
def _extract_url_patterns(self, html: str) -> List[str]:
"""Extract common URL patterns."""
href_pattern = r'href=["\']([^"\']+)["\']'
links = re.findall(href_pattern, html, re.IGNORECASE)
# Extract unique path segments
patterns = set()
for link in links:
try:
parsed = urlparse(link)
path = parsed.path.strip('/')
if path:
segments = path.split('/')
if segments:
patterns.add(f"/{segments[0]}/")
except:
pass
return sorted(list(patterns))[:20] # Top 20 patterns
def _detect_js_dependency(self, html: str) -> bool:
"""Detect if site heavily relies on JavaScript."""
# Look for SPA frameworks
spa_indicators = [
'react', 'vue', 'angular', 'next.js', 'nuxt',
'data-react', 'ng-app', 'v-app', '__NEXT_DATA__'
]
html_lower = html.lower()
for indicator in spa_indicators:
if indicator in html_lower:
return True
# Count script tags
script_count = len(re.findall(r'', '', html, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
# Remove HTML tags
text = re.sub(r'<[^>]+>', ' ', text)
# Clean up whitespace
text = re.sub(r'\s+', ' ', text).strip()
return text[:500]
def _detect_site_category(self, html: str, url: str) -> List[str]:
"""Detect site category based on content analysis."""
html_lower = html.lower()
url_lower = url.lower()
categories = []
# E-commerce indicators
if any(term in html_lower for term in ['add to cart', 'shopping cart', 'checkout', 'buy now', 'add to bag']):
categories.append('e-commerce')
# Configurator indicators
if any(term in html_lower for term in ['configure', 'build your', 'customize', 'configurator', 'builder']):
categories.append('configurator')
# Portfolio/Agency indicators
if any(term in html_lower for term in ['our work', 'portfolio', 'case studies', 'our projects']):
categories.append('portfolio')
# Service-based indicators
if any(term in html_lower for term in ['our services', 'what we do', 'service', 'consultation', 'contact us for']):
categories.append('services')
# Manufacturing/Industrial
if any(term in html_lower for term in ['manufacturer', 'industrial', 'wholesale', 'distributor']):
categories.append('manufacturing')
# Construction/Contractor
if any(term in html_lower for term in ['contractor', 'construction', 'installation', 'remodeling']):
categories.append('construction')
# Detect specific industries from URL and content
industry_keywords = {
'sauna': ['sauna', 'spa'],
'rv': ['rv', 'recreational vehicle', 'motorhome', 'camper'],
'automotive': ['automotive', 'car', 'vehicle', 'auto'],
'home': ['home', 'house', 'residential'],
'furniture': ['furniture', 'furnishing'],
'technology': ['software', 'tech', 'digital', 'app'],
'fashion': ['fashion', 'clothing', 'apparel'],
'food': ['restaurant', 'food', 'catering', 'menu']
}
for industry, keywords in industry_keywords.items():
if any(kw in html_lower or kw in url_lower for kw in keywords):
categories.append(industry)
return categories[:5] # Return top 5 categories
def _extract_navbar_links(self, html: str, base_url: str) -> List[Dict]:
"""Extract navigation bar links - the richest signal about site structure."""
from urllib.parse import urljoin, urlparse
navbar_links = []
# Try to find nav element first
nav_match = re.search(r'', html, re.IGNORECASE | re.DOTALL)
if nav_match:
nav_html = nav_match.group(1)
else:
# Fallback: look for header with navigation-like classes
header_match = re.search(r'