Spaces:
Sleeping
Sleeping
| """ | |
| 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 ( | |
| "<div style='text-align:center;padding:40px;'>" | |
| "<h3>No hotels found matching your criteria.</h3>" | |
| "<p>Try broadening your search — use a larger area or relax price/feature constraints.</p>" | |
| "</div>" | |
| ) | |
| 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"<img src='{img_url}' style='width:200px;height:150px;object-fit:cover;" | |
| f"border-radius:8px;' onerror=\"this.style.display='none'\">" | |
| if img_url | |
| else "<div style='width:200px;height:150px;background:#e0e0e0;border-radius:8px;" | |
| "display:flex;align-items:center;justify-content:center;color:#999;" | |
| "font-size:14px;'>No Image</div>" | |
| ) | |
| # 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"<span style='background:#e8f5e9;color:#2e7d32;padding:2px 8px;" | |
| f"border-radius:12px;font-size:12px;margin:2px;display:inline-block;'>{str(a)}</span>" | |
| 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""" | |
| <div style='border:1px solid #e0e0e0;border-radius:12px;padding:20px;margin:12px 0; | |
| background:white;box-shadow:0 2px 8px rgba(0,0,0,0.08);display:flex;gap:20px; | |
| flex-wrap:wrap;'> | |
| <div style='flex-shrink:0;'>{img_html}</div> | |
| <div style='flex-grow:1;min-width:260px;'> | |
| <div style='display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;'> | |
| <div> | |
| <h3 style='margin:0 0 4px 0;color:#1a237e;'>#{idx} {safe_name}</h3> | |
| <span style='color:#666;font-size:14px;'>{stars}</span> | |
| </div> | |
| <div style='text-align:right;'> | |
| <div style='font-size:24px;font-weight:bold;color:#1a237e;'>{price}</div> | |
| <div style='font-size:12px;color:#666;'>per night</div> | |
| </div> | |
| </div> | |
| <div style='margin:8px 0;'> | |
| <span style='background:{rating_color};color:white;padding:2px 10px; | |
| border-radius:4px;font-weight:bold;font-size:14px;'>{rating}</span> | |
| <span style='color:#666;font-size:13px;margin-left:8px;'>{reviews} reviews</span> | |
| </div> | |
| <p style='color:#555;font-size:14px;margin:8px 0;line-height:1.4;'>{safe_desc}</p> | |
| <div style='margin:8px 0;'>{amenities_html}</div> | |
| <div style='margin-top:12px;'> | |
| <a href='{link}' target='_blank' rel='noopener noreferrer' | |
| style='background:#1a237e;color:white;padding:8px 20px;border-radius:6px; | |
| text-decoration:none;font-size:14px;font-weight:500; | |
| display:inline-block;'>Visit Hotel Website</a> | |
| </div> | |
| </div> | |
| </div>""" | |
| cards.append(card) | |
| except Exception: | |
| continue | |
| return "<div style='font-family:Arial,sans-serif;'>" + "".join(cards) + "</div>" | |
| # --------------------------------------------------------------------------- | |
| # Parsed-parameters summary (shown above results) | |
| # --------------------------------------------------------------------------- | |
| def parsed_summary_html(parsed: dict, total: int) -> str: | |
| parts = [f"<strong>Found {total} hotel(s)</strong>"] | |
| if parsed["location"]: | |
| parts.append(f"Location: <em>{parsed['location']}</em>") | |
| 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 ( | |
| "<div style='background:#e3f2fd;padding:15px;border-radius:8px;margin-bottom:15px;" | |
| "line-height:1.8;'>" + " | ".join(parts) + "</div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 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 ( | |
| "<div style='text-align:center;padding:40px;'>" | |
| "<h3>Please enter a hotel description to search.</h3></div>" | |
| ) | |
| 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 ( | |
| "<div style='text-align:center;padding:40px;'>" | |
| "<h3>SerpApi key required</h3>" | |
| "<p>Set <code>SERPAPI_KEY</code> as a Hugging Face Space secret, " | |
| "or place your key in <code>api_key.txt</code> for local testing.</p></div>" | |
| ) | |
| 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"<div style='text-align:center;padding:40px;'>" | |
| f"<h3>SerpApi Error</h3><p>{results['error']}</p></div>" | |
| ) | |
| 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"<div style='text-align:center;padding:40px;'>" | |
| f"<h3>Something went wrong</h3>" | |
| f"<p>{exc}</p>" | |
| f"<p>Double-check your API key and try again.</p></div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 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() | |