""" Hotel Search App — Hugging Face Gradio Application Searches for hotels using natural language via SerpApi Google Hotels engine. """ import os import re from datetime import datetime, timedelta import gradio as gr from dateutil import parser as date_parser try: from serpapi import GoogleSearch except ImportError: import serpapi GoogleSearch = None # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- TRAVEL_AGENCY_DOMAINS = [ "expedia", "booking.com", "hotels.com", "trivago", "kayak", "priceline", "orbitz", "travelocity", "agoda", "trip.com", "hotwire", "cheaptickets", "lastminute", "edreams", "opodo", "wotif", "zuji", "makemytrip", "goibibo", "yatra", ] AMENITY_KEYWORDS = [ "pool", "gym", "fitness", "spa", "wifi", "wi-fi", "parking", "breakfast", "restaurant", "bar", "pet-friendly", "pets", "non-smoking", "smoke-free", "air conditioning", "laundry", "room service", "concierge", "shuttle", "beach", "ocean view", "balcony", "kitchen", "kitchenette", "suite", "jacuzzi", "hot tub", "business center", "ev charging", ] # --------------------------------------------------------------------------- # Input parser # --------------------------------------------------------------------------- def parse_user_input(text: str) -> dict: """Extract structured hotel search parameters from free-form text.""" result = { "location": "", "check_in": "", "check_out": "", "adults": 2, "min_price": None, "max_price": None, "required_features": [], "desired_features": [], "avoid_features": [], } # --- Dates ---------------------------------------------------------- date_patterns = [ r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}", r"\d{4}[/-]\d{1,2}[/-]\d{1,2}", r"(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(?:\s*,?\s*\d{4})?", ] raw_dates: list[str] = [] for pat in date_patterns: raw_dates.extend(re.findall(pat, text, re.IGNORECASE)) parsed_dates: list[datetime] = [] for raw in raw_dates: try: parsed_dates.append(date_parser.parse(raw, fuzzy=True)) except (ValueError, OverflowError): continue date_range = re.search( r"(\w+\s+\d{1,2})\s*[-–to]+\s*(\d{1,2})\s*,?\s*(\d{4})?", text, re.IGNORECASE ) if date_range and not parsed_dates: try: month_day_start = date_range.group(1) day_end = date_range.group(2) year = date_range.group(3) or str(datetime.now().year) start = date_parser.parse(f"{month_day_start} {year}") month_name = re.match(r"[A-Za-z]+", month_day_start).group() end = date_parser.parse(f"{month_name} {day_end} {year}") parsed_dates = [start, end] except (ValueError, AttributeError): pass if len(parsed_dates) >= 2: parsed_dates.sort() result["check_in"] = parsed_dates[0].strftime("%Y-%m-%d") result["check_out"] = parsed_dates[1].strftime("%Y-%m-%d") elif len(parsed_dates) == 1: result["check_in"] = parsed_dates[0].strftime("%Y-%m-%d") result["check_out"] = (parsed_dates[0] + timedelta(days=1)).strftime("%Y-%m-%d") # --- Price ---------------------------------------------------------- range_match = re.search( r"\$\s*(\d+)\s*(?:to|-|–)\s*\$\s*(\d+)", text, re.IGNORECASE ) if range_match: result["min_price"] = int(range_match.group(1)) result["max_price"] = int(range_match.group(2)) else: upper = re.search( r"(?:under|below|less than|max(?:imum)?|budget\s*(?:of|is|:)?|up to|cheaper than)\s*\$?\s*(\d+)", text, re.IGNORECASE, ) if upper: result["max_price"] = int(upper.group(1)) lower = re.search( r"(?:above|over|more than|at least|min(?:imum)?|starting at)\s*\$?\s*(\d+)", text, re.IGNORECASE, ) if lower: result["min_price"] = int(lower.group(1)) # --- Adults --------------------------------------------------------- adults_match = re.search(r"(\d+)\s*(?:adults?|guests?|people|persons?|travelers?)", text, re.IGNORECASE) if adults_match: result["adults"] = max(1, int(adults_match.group(1))) # --- Location ------------------------------------------------------- loc_patterns = [ r"(?:hotels?\s+)?(?:in|near|around|close to|next to|at)\s+([A-Z][A-Za-z\s,.']+?)(?:\s+(?:from|for|on|with|under|below|between|that|which|checking|budget|\d|\.|\,\s*(?:I|we|for|from|checking)))", r"(?:hotels?\s+)?(?:in|near|around|close to|next to|at)\s+([A-Z][A-Za-z\s,.']+?)$", ] for pat in loc_patterns: m = re.search(pat, text) if m: loc = m.group(1).strip().rstrip(" .,") if len(loc) > 2: result["location"] = loc break # --- Features ------------------------------------------------------- req_patterns = [ r"(?:must have|essential|has to have|mandatory)\s+(.+?)(?:\.|$)", r"(?:require[sd]?|need[sd]?|should have)\s+(?!a hotel|a room|a place|an? )(.+?)(?:\.|$)", ] des_patterns = [ r"(?:would (?:like|love|prefer)|prefer(?:ably)?|nice to have|ideally|hopefully)\s+(.+?)(?:\.|$)", ] avoid_pats = [ r"(?:(?:don'?t|do not) want|avoid|without|no |not interested in|stay away from)\s*(.+?)(?:\.|$)", ] for pats, key in [(req_patterns, "required_features"), (des_patterns, "desired_features"), (avoid_pats, "avoid_features")]: for pat in pats: for m in re.finditer(pat, text, re.IGNORECASE): features = [f.strip() for f in re.split(r",|(?:\s+and\s+)", m.group(1)) if f.strip()] result[key].extend(features) return result # --------------------------------------------------------------------------- # Hotel link resolver # --------------------------------------------------------------------------- def get_hotel_link(hotel: dict) -> str: """Return the best non-travel-agency link for a hotel.""" for field in ("link", "website"): url = hotel.get(field, "") if url and not any(agency in url.lower() for agency in TRAVEL_AGENCY_DOMAINS): return url prices = hotel.get("prices", []) for p in prices: source = (p.get("source") or "").lower() if "official" in source or hotel.get("name", "").lower().split()[0] in source: link = p.get("link", "") if link: return link name = hotel.get("name", "Hotel") return f"https://www.google.com/search?q={name.replace(' ', '+')}+official+site" # --------------------------------------------------------------------------- # Result formatter # --------------------------------------------------------------------------- def format_hotel_results(properties: list[dict]) -> str: """Render hotel results as styled HTML cards.""" if not properties: return ( "
" "

No hotels found matching your criteria.

" "

Try broadening your search — use a larger area or relax price/feature constraints.

" "
" ) cards = [] for idx, hotel in enumerate(properties[:15], 1): try: name = str(hotel.get("name", "Unknown Hotel") or "Unknown Hotel") rating = hotel.get("overall_rating", "N/A") reviews = hotel.get("reviews", 0) or 0 description = str(hotel.get("description", "") or "No description available.") rpn = hotel.get("rate_per_night") or {} price = rpn.get("lowest", "N/A") if isinstance(rpn, dict) else "N/A" hotel_class = hotel.get("hotel_class", "") or "" amenities = hotel.get("amenities") or [] images = hotel.get("images") or [] link = get_hotel_link(hotel) # Thumbnail img_url = "" if images: first = images[0] if isinstance(first, dict): img_url = first.get("thumbnail") or first.get("original_image", "") elif isinstance(first, str): img_url = first img_html = ( f"" if img_url else "
No Image
" ) # Stars stars = "" if hotel_class: try: n = int(float(str(hotel_class).replace("-star", "").replace("star", "").replace("hotel", "").strip())) stars = "⭐" * min(n, 5) except (ValueError, TypeError): stars = str(hotel_class) # Rating colour try: r = float(str(rating)) rating_color = "#4CAF50" if r >= 4.0 else "#FF9800" if r >= 3.0 else "#f44336" except (ValueError, TypeError): rating_color = "#9e9e9e" # Amenities chips (show up to 8) amenities_html = " ".join( f"{str(a)}" for a in (amenities[:8] if amenities else []) ) # Escape special HTML characters in user-facing text safe_name = name.replace("&", "&").replace("<", "<").replace(">", ">") safe_desc = description[:220].replace("&", "&").replace("<", "<").replace(">", ">") if len(description) > 220: safe_desc += "..." card = f"""
{img_html}

#{idx} {safe_name}

{stars}
{price}
per night
{rating} {reviews} reviews

{safe_desc}

{amenities_html}
Visit Hotel Website
""" cards.append(card) except Exception: continue return "
" + "".join(cards) + "
" # --------------------------------------------------------------------------- # Parsed-parameters summary (shown above results) # --------------------------------------------------------------------------- def parsed_summary_html(parsed: dict, total: int) -> str: parts = [f"Found {total} hotel(s)"] if parsed["location"]: parts.append(f"Location: {parsed['location']}") if parsed["check_in"]: parts.append(f"Check-in: {parsed['check_in']}") if parsed["check_out"]: parts.append(f"Check-out: {parsed['check_out']}") if parsed["adults"] != 2: parts.append(f"Guests: {parsed['adults']}") if parsed["min_price"] and parsed["max_price"]: parts.append(f"Budget: ${parsed['min_price']}–${parsed['max_price']}/night") elif parsed["max_price"]: parts.append(f"Budget: up to ${parsed['max_price']}/night") elif parsed["min_price"]: parts.append(f"Budget: ${parsed['min_price']}+/night") if parsed["required_features"]: parts.append(f"Required: {', '.join(parsed['required_features'])}") if parsed["desired_features"]: parts.append(f"Preferred: {', '.join(parsed['desired_features'])}") if parsed["avoid_features"]: parts.append(f"Avoiding: {', '.join(parsed['avoid_features'])}") return ( "
" + "  |  ".join(parts) + "
" ) # --------------------------------------------------------------------------- # Main search function # --------------------------------------------------------------------------- def search_hotels(user_input: str) -> str: """Parse user input, call SerpApi, and return formatted HTML results.""" if not user_input or not user_input.strip(): return ( "
" "

Please enter a hotel description to search.

" ) key = os.environ.get("SERPAPI_KEY", "") or os.environ.get("SERPAPI_API_KEY", "") if not key: possible_paths = [ os.path.join(os.getcwd(), "api_key.txt"), os.path.join(os.path.expanduser("~"), "Desktop", "Hotel App", "api_key.txt"), ] for key_file in possible_paths: if os.path.exists(key_file): with open(key_file) as f: key = f.read().strip() if key: break if not key: return ( "
" "

SerpApi key required

" "

Set SERPAPI_KEY as a Hugging Face Space secret, " "or place your key in api_key.txt for local testing.

" ) try: parsed = parse_user_input(user_input) query = parsed["location"] if parsed["location"] else user_input.split(".")[0][:120] tomorrow = datetime.now() + timedelta(days=1) check_in = parsed["check_in"] or tomorrow.strftime("%Y-%m-%d") ci_dt = datetime.strptime(check_in, "%Y-%m-%d") if ci_dt.date() < datetime.now().date(): ci_dt = tomorrow check_in = ci_dt.strftime("%Y-%m-%d") check_out = parsed["check_out"] or (ci_dt + timedelta(days=1)).strftime("%Y-%m-%d") params: dict = { "engine": "google_hotels", "q": query, "check_in_date": check_in, "check_out_date": check_out, "adults": parsed["adults"], "currency": "USD", "gl": "us", "hl": "en", "api_key": key, } if parsed["min_price"]: params["min_price"] = parsed["min_price"] if parsed["max_price"]: params["max_price"] = parsed["max_price"] if GoogleSearch is not None: search = GoogleSearch(params) results = search.get_dict() else: results = serpapi.search(params) if "error" in results: return ( f"
" f"

SerpApi Error

{results['error']}

" ) properties = results.get("properties", []) filtered = [ h for h in properties if not any(agency in get_hotel_link(h).lower() for agency in TRAVEL_AGENCY_DOMAINS) ] if len(filtered) < 3 and properties: filtered = properties summary = parsed_summary_html(parsed, len(filtered)) return summary + format_hotel_results(filtered) except Exception as exc: return ( f"
" f"

Something went wrong

" f"

{exc}

" f"

Double-check your API key and try again.

" ) # --------------------------------------------------------------------------- # Gradio UI # --------------------------------------------------------------------------- with gr.Blocks(title="AI Hotel Search") as app: gr.Markdown( "# AI Hotel Search\n" "### Find your perfect hotel using natural language\n" "Describe what you are looking for in plain English — include location, dates, " "budget, and any features you want or want to avoid." ) user_input = gr.Textbox( label="Describe Your Ideal Hotel", placeholder=( "Example: I'm looking for a quiet, non-smoking hotel in downtown " "Austin, TX from March 15 to March 18, 2026. Budget under $200/night. " "Must have free parking and wifi. Would prefer a pool. Avoid hotels " "near highways." ), lines=5, ) search_btn = gr.Button("Search Hotels", variant="primary", size="lg") with gr.Accordion("Search Tips & Examples", open=False): gr.Markdown( "**Good search examples:**\n\n" "- *\"Find me a hotel in San Francisco near Fisherman's Wharf, checking in " "April 5 and out April 8, 2026. Budget under $250/night. Must be non-smoking " "with free wifi. Would like ocean view.\"*\n" "- *\"Pet-friendly hotel in Nashville, TN for 2 adults from May 10-12, 2026. " "Price range $100-$180. Prefer boutique hotels. Avoid large chain hotels.\"*\n" "- *\"Luxury hotel in Miami Beach, March 20-23, 2026. $300-$500/night. Must " "have spa and pool. Prefer beachfront.\"*\n\n" "**Tips for best results:**\n\n" "- Always include a **location** (city, state, or landmark).\n" "- Specify **dates** in common formats (MM/DD/YYYY, Month Day Year, etc.).\n" "- Mention your **budget** with dollar amounts.\n" "- Distinguish between **must-have** and **nice-to-have** features.\n" "- State features to **avoid** explicitly.\n\n" "**Bad example (too vague):**\n\n" "- *\"Find me a nice hotel somewhere warm.\"* — no location, no dates, no budget." ) results_output = gr.HTML(label="Search Results") search_btn.click(fn=search_hotels, inputs=[user_input], outputs=results_output) user_input.submit(fn=search_hotels, inputs=[user_input], outputs=results_output) if __name__ == "__main__": app.launch()