""" Web search module for Gift Finder to search for real gift products. This module uses SerpAPI (or falls back to custom web scraping) to find real gift recommendations based on the search queries generated by the Gift Finder model. """ import os import json import logging import random import time import hashlib from datetime import datetime, timedelta from typing import List, Dict, Any, Optional import requests from dotenv import load_dotenv # Load environment variables load_dotenv() # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class GiftSearchEngine: """Search engine for finding gift products online.""" def __init__(self, api_key: Optional[str] = None, cache_ttl: int = 3600): """ Initialize the gift search engine. Args: api_key: API key for SerpAPI (optional) cache_ttl: Cache time-to-live in seconds (default: 1 hour) """ self.api_key = api_key or os.environ.get("SERPAPI_API_KEY") self.cache = {} # In-memory cache self.cache_ttl = cache_ttl if not self.api_key: logger.warning("No SerpAPI key provided. Will use fallback search methods.") def search_gifts(self, query: str, num_results: int = 5, price_min: Optional[float] = None, price_max: Optional[float] = None) -> List[Dict[str, Any]]: """ Search for gifts based on the query. Args: query: The search query num_results: Number of results to return price_min: Minimum price (optional) price_max: Maximum price (optional) Returns: List of gift recommendations """ # Create a cache key cache_key = self._create_cache_key(query, num_results, price_min, price_max) # Check cache cached_results = self._get_from_cache(cache_key) if cached_results: logger.info(f"Using cached results for query: {query}") return cached_results # No cache hit, perform search if self.api_key: try: results = self._search_with_serpapi(query, num_results, price_min, price_max) if results: # Cache the results self._add_to_cache(cache_key, results) return results except Exception as e: logger.error(f"Error using SerpAPI: {str(e)}") logger.warning("Falling back to alternative search method") # Fallback to free alternative results = self._search_with_fallback(query, num_results, price_min, price_max) # Cache the fallback results too self._add_to_cache(cache_key, results) return results def _create_cache_key(self, query: str, num_results: int, price_min: Optional[float], price_max: Optional[float]) -> str: """Create a unique cache key from the search parameters.""" key_parts = [ query.lower().strip(), str(num_results), str(price_min) if price_min is not None else "none", str(price_max) if price_max is not None else "none" ] key_string = ":".join(key_parts) return hashlib.md5(key_string.encode()).hexdigest() def _add_to_cache(self, key: str, results: List[Dict[str, Any]]) -> None: """Add results to the cache with expiration time.""" self.cache[key] = { "results": results, "expires": datetime.now() + timedelta(seconds=self.cache_ttl) } def _get_from_cache(self, key: str) -> Optional[List[Dict[str, Any]]]: """Get results from cache if they exist and are not expired.""" if key in self.cache: cache_item = self.cache[key] if cache_item["expires"] > datetime.now(): return cache_item["results"] else: # Expired, remove from cache del self.cache[key] return None def _search_with_serpapi(self, query: str, num_results: int = 5, price_min: Optional[float] = None, price_max: Optional[float] = None) -> List[Dict[str, Any]]: """ Search for gifts using SerpAPI. Args: query: The search query num_results: Number of results to return price_min: Minimum price (optional) price_max: Maximum price (optional) Returns: List of gift recommendations """ logger.info(f"Searching for gifts with SerpAPI: {query}") # Prepare the search parameters params = { "api_key": self.api_key, "engine": "google_shopping", "q": f"{query}", "num": num_results, "hl": "en", "gl": "us" } # Add price range if provided if price_min is not None: params["price_min"] = price_min if price_max is not None: params["price_max"] = price_max # Make the request response = requests.get( "https://serpapi.com/search", params=params ) if response.status_code != 200: logger.error(f"SerpAPI error: {response.status_code} - {response.text}") return [] # Parse the response try: data = response.json() shopping_results = data.get("shopping_results", []) if not shopping_results: logger.warning("No shopping results found") return [] # Format the results results = [] for item in shopping_results[:num_results]: if 'title' not in item or 'link' not in item: continue result = { "name": item.get("title", ""), "price": item.get("price", ""), "url": item.get("link", ""), "description": item.get("snippet", ""), "image": item.get("thumbnail", ""), "source": "serpapi" } results.append(result) return results except Exception as e: logger.error(f"Error parsing SerpAPI response: {str(e)}") return [] def _search_with_fallback(self, query: str, num_results: int = 5, price_min: Optional[float] = None, price_max: Optional[float] = None) -> List[Dict[str, Any]]: """ Fallback search method when SerpAPI is not available. This uses alternative free methods or simulates results. Args: query: The search query num_results: Number of results to return price_min: Minimum price (optional) price_max: Maximum price (optional) Returns: List of gift recommendations """ logger.info(f"Using fallback search for: {query}") # Extract key terms from the query to make better fallback recommendations terms = query.lower().split() occasion = next((term for term in ["birthday", "christmas", "anniversary", "wedding", "graduation"] if term in terms), "gift") relationship = next((term for term in ["brother", "sister", "mom", "dad", "wife", "husband", "friend", "girlfriend", "boyfriend"] if term in terms), "person") interests = [] interest_keywords = ["enjoys", "likes", "loves", "fan", "hobby", "interest", "passion"] for i, word in enumerate(terms): if word in interest_keywords and i+1 < len(terms): # Get the next few words as potential interests interests.extend(terms[i+1:i+5]) # Price range budget_terms = { "cheap": (5, 30), "inexpensive": (5, 30), "affordable": (20, 50), "moderate": (30, 100), "expensive": (75, 200), "luxury": (150, 500) } budget_term = next((term for term in budget_terms if term in terms), "moderate") budget_range = budget_terms.get(budget_term, (30, 100)) if price_min is None: price_min = budget_range[0] if price_max is None: price_max = budget_range[1] # Generate fallback gift ideas based on extracted information products = self._generate_gift_ideas(occasion, relationship, interests, price_min, price_max) # Take only requested number of results return products[:num_results] def _generate_gift_ideas(self, occasion: str, relationship: str, interests: List[str], price_min: float, price_max: float) -> List[Dict[str, Any]]: """ Generate realistic gift ideas based on the extracted information. Args: occasion: The gift occasion relationship: Relationship to the recipient interests: List of recipient interests price_min: Minimum price price_max: Maximum price Returns: List of gift recommendations """ # Base set of gift ideas categorized by interest gift_ideas = { "gaming": [ {"name": "Nintendo Switch Lite", "price": "$199.99", "description": "Portable gaming console with access to a vast library of games"}, {"name": "PlayStation Store Gift Card", "price": "$50.00", "description": "Digital gift card for PlayStation games and content"}, {"name": "Gaming Mouse", "price": "$29.99", "description": "High-precision gaming mouse with customizable RGB lighting"}, {"name": "Gaming Headset", "price": "$59.99", "description": "Immersive gaming headset with noise-canceling microphone"}, {"name": "Xbox Game Pass Subscription", "price": "$44.99", "description": "3-month subscription to Xbox Game Pass with access to hundreds of games"} ], "reading": [ {"name": "Kindle Paperwhite", "price": "$139.99", "description": "Waterproof e-reader with adjustable warm light"}, {"name": "Book of the Month Subscription", "price": "$49.99", "description": "3-month subscription to receive bestselling books"}, {"name": "Personalized Bookmark Set", "price": "$15.99", "description": "Set of 3 custom engraved metal bookmarks"}, {"name": "Reading Light", "price": "$19.99", "description": "Rechargeable LED reading light with multiple brightness levels"}, {"name": "Literary Candle", "price": "$24.99", "description": "Book-inspired scented candle perfect for reading sessions"} ], "cooking": [ {"name": "Instant Pot Duo", "price": "$89.99", "description": "7-in-1 multi-functional pressure cooker"}, {"name": "Chef's Knife", "price": "$49.99", "description": "High-carbon stainless steel chef's knife for precision cutting"}, {"name": "Spice Set", "price": "$35.99", "description": "Collection of 20 essential cooking spices in glass jars"}, {"name": "Digital Kitchen Scale", "price": "$22.99", "description": "Precise digital scale for cooking and baking"}, {"name": "Cookbook Stand", "price": "$29.99", "description": "Adjustable cookbook holder with splatter guard"} ], "music": [ {"name": "Bluetooth Speaker", "price": "$79.99", "description": "Portable wireless speaker with rich sound and long battery life"}, {"name": "Vinyl Record Player", "price": "$149.99", "description": "Vintage-style turntable with modern Bluetooth connectivity"}, {"name": "Spotify Premium Gift Card", "price": "$29.99", "description": "3-month subscription to ad-free music streaming"}, {"name": "Noise-Canceling Headphones", "price": "$129.99", "description": "Over-ear headphones with active noise cancellation"}, {"name": "Concert Ticket Gift Card", "price": "$50.00", "description": "Gift card toward concert tickets at major venues"} ], "sports": [ {"name": "Fitness Tracker", "price": "$89.99", "description": "Activity tracker with heart rate monitoring and GPS"}, {"name": "Basketball", "price": "$29.99", "description": "Official size indoor/outdoor basketball"}, {"name": "Sports Team Merchandise", "price": "$45.99", "description": "Official licensed team apparel"}, {"name": "Yoga Mat", "price": "$24.99", "description": "Non-slip exercise mat for yoga and fitness"}, {"name": "Insulated Water Bottle", "price": "$34.99", "description": "Vacuum-insulated stainless steel water bottle"} ], "technology": [ {"name": "Wireless Earbuds", "price": "$129.99", "description": "True wireless earbuds with noise cancellation and long battery life"}, {"name": "Smart Speaker", "price": "$99.99", "description": "Voice-controlled speaker with digital assistant"}, {"name": "Portable Charger", "price": "$49.99", "description": "20000mAh power bank for smartphones and tablets"}, {"name": "Smart Watch", "price": "$199.99", "description": "Fitness and health tracking smartwatch"}, {"name": "Wireless Charging Pad", "price": "$29.99", "description": "Fast wireless charger compatible with most smartphones"} ], "art": [ {"name": "Drawing Tablet", "price": "$79.99", "description": "Digital drawing tablet with pressure sensitivity"}, {"name": "Watercolor Paint Set", "price": "$45.99", "description": "Professional grade watercolor paints with brushes"}, {"name": "Adult Coloring Book Set", "price": "$24.99", "description": "Set of 3 detailed coloring books with pencils"}, {"name": "Digital Picture Frame", "price": "$99.99", "description": "Wi-Fi enabled digital frame that displays photos from the cloud"}, {"name": "Art Print", "price": "$35.99", "description": "Museum-quality art print from independent artists"} ], "travel": [ {"name": "Packing Cubes", "price": "$24.99", "description": "Set of 6 lightweight packing organizers for travel"}, {"name": "Travel Pillow", "price": "$29.99", "description": "Memory foam neck pillow for comfortable travel"}, {"name": "Scratch-Off World Map", "price": "$35.99", "description": "Interactive map to track visited countries"}, {"name": "Luggage Tag Set", "price": "$19.99", "description": "Set of 4 personalized luggage tags"}, {"name": "Universal Travel Adapter", "price": "$24.99", "description": "All-in-one adapter with USB ports for international travel"} ] } # Add generic gifts that work for most people gift_ideas["generic"] = [ {"name": "Custom Photo Frame", "price": "$29.99", "description": "Personalized photo frame with custom engraving"}, {"name": "Scented Candle Gift Set", "price": "$34.99", "description": "Set of 3 premium scented candles in gift box"}, {"name": "Gourmet Chocolate Box", "price": "$39.99", "description": "Assortment of premium chocolates from around the world"}, {"name": "Streaming Service Subscription", "price": "$44.99", "description": "3-month subscription to premium streaming service"}, {"name": "Premium Coffee Sampler", "price": "$24.99", "description": "Collection of specialty coffees from top roasters"} ] # Select the most relevant category based on interests categories = ["generic"] for interest in interests: for category in gift_ideas.keys(): if interest and category in interest: categories.append(category) # Ensure we have at least one specific category if len(categories) == 1: # Pick a random category as fallback categories.append(random.choice(list(gift_ideas.keys()))) # Collect all potential gifts from relevant categories potential_gifts = [] for category in categories: potential_gifts.extend(gift_ideas.get(category, [])) # Filter by price filtered_gifts = [] for gift in potential_gifts: price_str = gift["price"] price_value = float(price_str.replace("$", "").replace(",", "")) if price_min <= price_value <= price_max: filtered_gifts.append(gift) # If we don't have enough gifts after price filtering, add some anyway if len(filtered_gifts) < 3: filtered_gifts = potential_gifts # Shuffle the list for variety random.shuffle(filtered_gifts) # Format the results results = [] for item in filtered_gifts: # Add realistic product links from popular retailers retailers = { "amazon": "https://www.amazon.com/s?k=", "walmart": "https://www.walmart.com/search/?query=", "target": "https://www.target.com/s?searchTerm=", "bestbuy": "https://www.bestbuy.com/site/searchpage.jsp?st=" } retailer = random.choice(list(retailers.keys())) query_term = item["name"].replace(" ", "+") url = f"{retailers[retailer]}{query_term}" # Add image URLs for each product image_urls = { "gaming": "https://m.media-amazon.com/images/I/71S-OtNPVrL._AC_SL1500_.jpg", "reading": "https://m.media-amazon.com/images/I/61Ek12ufRxL._AC_SL1500_.jpg", "cooking": "https://m.media-amazon.com/images/I/71WtwLzGhWL._AC_SL1500_.jpg", "music": "https://m.media-amazon.com/images/I/61stQYWQO4L._AC_SL1500_.jpg", "sports": "https://m.media-amazon.com/images/I/61wEWNA5P4L._AC_SL1500_.jpg", "technology": "https://m.media-amazon.com/images/I/61aJaknbncL._AC_SL1500_.jpg", "art": "https://m.media-amazon.com/images/I/71KvYB+qyYL._AC_SL1500_.jpg", "travel": "https://m.media-amazon.com/images/I/81kJQoZcBfL._AC_SL1500_.jpg", "generic": "https://m.media-amazon.com/images/I/81O+V3oLSOL._AC_SL1500_.jpg" } # Find the most relevant category for this item relevant_category = "generic" for category in gift_ideas.keys(): if item in gift_ideas[category]: relevant_category = category break image_url = image_urls.get(relevant_category, image_urls["generic"]) result = { "name": item["name"], "price": item["price"], "url": url, "description": item["description"], "image": image_url, "retailer": retailer, "source": "custom_search" } results.append(result) return results # Example usage if __name__ == "__main__": # Initialize the search engine search_engine = GiftSearchEngine() # Example search query query = "birthday gift for brother who enjoys gaming and basketball under $50" # Search for gifts results = search_engine.search_gifts(query, num_results=3, price_max=50) # Print the results print(f"Search query: {query}") print(f"Found {len(results)} results:") for i, result in enumerate(results, 1): print(f"{i}. {result['name']} - {result['price']}") print(f" {result['description']}") print(f" {result['url']}") print()