|
| 1 | +import threading |
| 2 | +import requests |
| 3 | +import webbrowser |
| 4 | +import tkinter as tk |
| 5 | +from tkinter import messagebox |
| 6 | +from dataclasses import dataclass |
| 7 | +from typing import List, Dict |
| 8 | +import io |
| 9 | +import json |
| 10 | +import ttkbootstrap as tb |
| 11 | +from ttkbootstrap.widgets.scrolled import ScrolledText |
| 12 | +from PIL import Image, ImageTk |
| 13 | + |
| 14 | +# ---------------- CONFIG ---------------- # |
| 15 | +GOOGLE_API_KEY = "YOUR_GOOGLE_API_KEY" |
| 16 | +AFFILIATE_ID = "YOUR_AFFILIATE_ID" |
| 17 | +API_TOKEN = "YOUR_API_TOKEN" |
| 18 | +BASE_URL = "https://demandapi.booking.com/3.1" |
| 19 | +RESULTS_PER_PAGE = 6 |
| 20 | +FAVORITES_FILE = "accommodation_favorites.json" |
| 21 | + |
| 22 | +HEADERS = { |
| 23 | + "Content-Type": "application/json", |
| 24 | + "Authorization": f"Bearer {API_TOKEN}", |
| 25 | + "X-Affiliate-Id": str(AFFILIATE_ID) |
| 26 | +} |
| 27 | + |
| 28 | +# ---------------- IMAGE CACHE ---------------- # |
| 29 | +cover_cache: Dict[str, ImageTk.PhotoImage] = {} |
| 30 | + |
| 31 | +def load_cover(url: str, size=(120, 80)): |
| 32 | + if not url: |
| 33 | + return None |
| 34 | + if url in cover_cache: |
| 35 | + return cover_cache[url] |
| 36 | + try: |
| 37 | + img = Image.open(io.BytesIO(requests.get(url).content)).resize(size) |
| 38 | + photo = ImageTk.PhotoImage(img) |
| 39 | + cover_cache[url] = photo |
| 40 | + return photo |
| 41 | + except: |
| 42 | + return None |
| 43 | + |
| 44 | +# ---------------- DATA STRUCTURE ---------------- # |
| 45 | +@dataclass |
| 46 | +class Accommodation: |
| 47 | + name: str |
| 48 | + city: str |
| 49 | + price: float |
| 50 | + rating: float |
| 51 | + url: str |
| 52 | + image_url: str = "" # for preview images |
| 53 | + |
| 54 | +# ---------------- ENGINE ---------------- # |
| 55 | +class AccommodationEngine: |
| 56 | + def __init__(self): |
| 57 | + self.results: List[Accommodation] = [] |
| 58 | + self.current_page = 1 |
| 59 | + self.favorites: List[Accommodation] = [] |
| 60 | + self.selected_latlng = (None, None) |
| 61 | + self.load_favorites() |
| 62 | + self.build_ui() |
| 63 | + |
| 64 | + # ---------------- FAVORITES ---------------- # |
| 65 | + def load_favorites(self): |
| 66 | + try: |
| 67 | + with open(FAVORITES_FILE, "r", encoding="utf-8") as f: |
| 68 | + self.favorites = [Accommodation(**a) for a in json.load(f)] |
| 69 | + except FileNotFoundError: |
| 70 | + self.favorites = [] |
| 71 | + |
| 72 | + def save_favorites(self): |
| 73 | + with open(FAVORITES_FILE, "w", encoding="utf-8") as f: |
| 74 | + json.dump([a.__dict__ for a in self.favorites], f, indent=2) |
| 75 | + |
| 76 | + def add_to_favorites(self, acc: Accommodation): |
| 77 | + if acc not in self.favorites: |
| 78 | + self.favorites.append(acc) |
| 79 | + self.save_favorites() |
| 80 | + messagebox.showinfo("Saved", f"Added {acc.name} to Favorites") |
| 81 | + |
| 82 | + # ---------------- GOOGLE PLACES AUTOCOMPLETE ---------------- # |
| 83 | + def autocomplete_city(self, query: str): |
| 84 | + url = f"https://maps.googleapis.com/maps/api/place/autocomplete/json" |
| 85 | + params = {"input": query, "types": "(cities)", "key": GOOGLE_API_KEY} |
| 86 | + r = requests.get(url, params=params) |
| 87 | + return r.json().get("predictions", []) |
| 88 | + |
| 89 | + def get_city_location(self, place_id: str): |
| 90 | + url = f"https://maps.googleapis.com/maps/api/place/details/json" |
| 91 | + params = {"place_id": place_id, "fields": "geometry", "key": GOOGLE_API_KEY} |
| 92 | + r = requests.get(url, params=params) |
| 93 | + geom = r.json().get("result", {}).get("geometry", {}) |
| 94 | + loc = geom.get("location", {}) |
| 95 | + return loc.get("lat"), loc.get("lng") |
| 96 | + |
| 97 | + # ---------------- BOOKING.COM SEARCH ---------------- # |
| 98 | + def fetch_accommodations(self, lat, lng, radius, min_price, max_price, min_rating, amenities, room_types): |
| 99 | + body = { |
| 100 | + "geo": {"lat": lat, "lng": lng, "radius": radius}, |
| 101 | + "currency": "USD", |
| 102 | + "guests": {"number_of_adults": 2, "number_of_rooms": 1}, |
| 103 | + "price": {"minimum": min_price, "maximum": max_price}, |
| 104 | + "rating": {"minimum_review_score": min_rating}, |
| 105 | + "accommodation_types": room_types, |
| 106 | + "room_facilities": amenities, |
| 107 | + "rows": 100, |
| 108 | + "extras": ["extra_charges", "products"] |
| 109 | + } |
| 110 | + r = requests.post(f"{BASE_URL}/accommodations/search", headers=HEADERS, json=body) |
| 111 | + data = r.json().get("data", []) |
| 112 | + results = [] |
| 113 | + for item in data: |
| 114 | + try: |
| 115 | + name = item.get("name") |
| 116 | + city = item.get("address", {}).get("city_name", "") |
| 117 | + price = float(item.get("price", {}).get("total", 0)) |
| 118 | + rating = float(item.get("review_score", 0)) |
| 119 | + url = item.get("url") |
| 120 | + image_url = item.get("photo_main", "") |
| 121 | + results.append(Accommodation(name, city, price, rating, url, image_url)) |
| 122 | + except: |
| 123 | + continue |
| 124 | + return results |
| 125 | + |
| 126 | + # ---------------- UI ---------------- # |
| 127 | + def build_ui(self): |
| 128 | + self.app = tb.Window(title="Accommodation Engine", themename="darkly", size=(1000, 700)) |
| 129 | + |
| 130 | + top = tb.Frame(self.app, padding=15) |
| 131 | + top.pack(fill=tk.X) |
| 132 | + |
| 133 | + tb.Label(top, text="🏨 Accommodation Finder", font=("Segoe UI", 18, "bold")).pack(anchor=tk.W) |
| 134 | + |
| 135 | + # City search with autocomplete |
| 136 | + self.city_var = tk.StringVar() |
| 137 | + city_entry = tb.Entry(top, textvariable=self.city_var, font=("Segoe UI", 13)) |
| 138 | + city_entry.pack(fill=tk.X, pady=4) |
| 139 | + city_entry.bind("<KeyRelease>", self.on_city_key) |
| 140 | + |
| 141 | + self.city_suggestions = tk.Listbox(top, height=5) |
| 142 | + self.city_suggestions.pack(fill=tk.X) |
| 143 | + self.city_suggestions.bind("<<ListboxSelect>>", self.on_city_select) |
| 144 | + |
| 145 | + # Distance radius slider |
| 146 | + tb.Label(top, text="Distance Radius (km)").pack(anchor=tk.W) |
| 147 | + self.radius_var = tk.DoubleVar(value=5) |
| 148 | + tb.Scale(top, from_=1, to=50, variable=self.radius_var, orient="horizontal").pack(fill=tk.X, pady=2) |
| 149 | + |
| 150 | + # Filters |
| 151 | + filter_frame = tb.Frame(top) |
| 152 | + filter_frame.pack(fill=tk.X) |
| 153 | + self.min_price = tk.StringVar(value="0") |
| 154 | + self.max_price = tk.StringVar(value="1000") |
| 155 | + self.min_rating = tk.StringVar(value="0") |
| 156 | + tb.Entry(filter_frame, textvariable=self.min_price, width=8).pack(side=tk.LEFT, padx=2) |
| 157 | + tb.Entry(filter_frame, textvariable=self.max_price, width=8).pack(side=tk.LEFT, padx=2) |
| 158 | + tb.Entry(filter_frame, textvariable=self.min_rating, width=8).pack(side=tk.LEFT, padx=2) |
| 159 | + tb.Label(filter_frame, text="MinPrice MaxPrice MinRating").pack(side=tk.LEFT, padx=5) |
| 160 | + |
| 161 | + # Amenity checkboxes |
| 162 | + amenity_frame = tb.LabelFrame(top, text="Amenities") |
| 163 | + amenity_frame.pack(fill=tk.X, pady=2) |
| 164 | + self.amenity_vars = {} |
| 165 | + amenities = ["Pool", "Wifi", "Parking", "Gym"] |
| 166 | + for a in amenities: |
| 167 | + var = tk.IntVar() |
| 168 | + self.amenity_vars[a] = var |
| 169 | + tb.Checkbutton(amenity_frame, text=a, variable=var).pack(side=tk.LEFT, padx=5) |
| 170 | + |
| 171 | + # Room type checkboxes |
| 172 | + room_frame = tb.LabelFrame(top, text="Room Types") |
| 173 | + room_frame.pack(fill=tk.X, pady=2) |
| 174 | + self.room_vars = {} |
| 175 | + room_types = ["Hotel", "Apartment", "Villa", "B&B"] |
| 176 | + for r in room_types: |
| 177 | + var = tk.IntVar() |
| 178 | + self.room_vars[r] = var |
| 179 | + tb.Checkbutton(room_frame, text=r, variable=var).pack(side=tk.LEFT, padx=5) |
| 180 | + |
| 181 | + tb.Button(top, text="Search", bootstyle="primary", command=self.perform_search).pack(pady=6) |
| 182 | + |
| 183 | + # Results |
| 184 | + result_frame = tb.Frame(self.app) |
| 185 | + result_frame.pack(fill=tk.BOTH, expand=True) |
| 186 | + self.result_box = ScrolledText(result_frame) |
| 187 | + self.result_box.pack(fill=tk.BOTH, expand=True) |
| 188 | + self.text = self.result_box.text |
| 189 | + self.text.configure(state="disabled", wrap="word") |
| 190 | + |
| 191 | + # Pagination & favorites |
| 192 | + nav = tb.Frame(self.app) |
| 193 | + nav.pack(fill=tk.X) |
| 194 | + tb.Button(nav, text="← Prev", command=self.prev_page).pack(side=tk.LEFT) |
| 195 | + self.page_label = tb.Label(nav, text="Page 1"); self.page_label.pack(side=tk.LEFT, padx=10) |
| 196 | + tb.Button(nav, text="Next →", command=self.next_page).pack(side=tk.LEFT) |
| 197 | + tb.Button(nav, text="Favorites", bootstyle="success", command=self.show_favorites).pack(side=tk.RIGHT) |
| 198 | + |
| 199 | + self.app.mainloop() |
| 200 | + |
| 201 | + # ---------------- CITY AUTOCOMPLETE EVENTS ---------------- # |
| 202 | + def on_city_key(self, event): |
| 203 | + query = self.city_var.get() |
| 204 | + if len(query) < 2: |
| 205 | + return |
| 206 | + suggestions = self.autocomplete_city(query) |
| 207 | + self.city_suggestions.delete(0, tk.END) |
| 208 | + for s in suggestions: |
| 209 | + self.city_suggestions.insert(tk.END, f"{s['description']}||{s['place_id']}") |
| 210 | + |
| 211 | + def on_city_select(self, event): |
| 212 | + sel = self.city_suggestions.curselection() |
| 213 | + if sel: |
| 214 | + val = self.city_suggestions.get(sel[0]) |
| 215 | + desc, pid = val.split("||") |
| 216 | + self.city_var.set(desc) |
| 217 | + lat, lng = self.get_city_location(pid) |
| 218 | + self.selected_latlng = (lat, lng) |
| 219 | + self.city_suggestions.delete(0, tk.END) |
| 220 | + |
| 221 | + # ---------------- SEARCH ---------------- # |
| 222 | + def perform_search(self): |
| 223 | + if not self.selected_latlng[0]: |
| 224 | + messagebox.showerror("Error", "Select a city from suggestions") |
| 225 | + return |
| 226 | + lat, lng = self.selected_latlng |
| 227 | + radius = self.radius_var.get() |
| 228 | + min_p = float(self.min_price.get()) |
| 229 | + max_p = float(self.max_price.get()) |
| 230 | + min_r = float(self.min_rating.get()) |
| 231 | + amenities = [i for i, v in self.amenity_vars.items() if v.get() == 1] |
| 232 | + room_types = [i for i, v in self.room_vars.items() if v.get() == 1] |
| 233 | + |
| 234 | + threading.Thread(target=self.search_thread, |
| 235 | + args=(lat, lng, radius, min_p, max_p, min_r, amenities, room_types), daemon=True).start() |
| 236 | + |
| 237 | + def search_thread(self, lat, lng, radius, min_p, max_p, min_r, amenities, room_types): |
| 238 | + self.results = self.fetch_accommodations(lat, lng, radius, min_p, max_p, min_r, amenities, room_types) |
| 239 | + self.current_page = 1 |
| 240 | + self.display_page() |
| 241 | + |
| 242 | + # ---------------- DISPLAY WITH IMAGE ---------------- # |
| 243 | + def display_page(self): |
| 244 | + self.text.configure(state="normal") |
| 245 | + self.text.delete("1.0", "end") |
| 246 | + start = (self.current_page - 1) * RESULTS_PER_PAGE |
| 247 | + end = start + RESULTS_PER_PAGE |
| 248 | + for acc in self.results[start:end]: |
| 249 | + self.insert_accommodation(self.text, acc) |
| 250 | + self.text.configure(state="disabled") |
| 251 | + self.page_label.config(text=f"Page {self.current_page}") |
| 252 | + |
| 253 | + def insert_accommodation(self, container_widget, acc: Accommodation): |
| 254 | + frame = tk.Frame(container_widget) |
| 255 | + art_label = tk.Label(frame) |
| 256 | + art_label.pack(side=tk.LEFT, padx=5, pady=5) |
| 257 | + |
| 258 | + info_frame = tk.Frame(frame) |
| 259 | + info_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) |
| 260 | + |
| 261 | + tb.Label(info_frame, text=f"{acc.name}", font=("Segoe UI", 10, "bold")).pack(anchor=tk.W) |
| 262 | + tb.Label(info_frame, text=f"{acc.city} | ${acc.price} ⭐ {acc.rating}", font=("Segoe UI", 9)).pack(anchor=tk.W) |
| 263 | + |
| 264 | + btn_frame = tk.Frame(info_frame) |
| 265 | + btn_frame.pack(anchor=tk.W, pady=2) |
| 266 | + tb.Button(btn_frame, text="★ Favorite", bootstyle="warning", command=lambda a=acc: self.add_to_favorites(a)).pack(side=tk.LEFT, padx=2) |
| 267 | + tb.Button(btn_frame, text="↗ Open", bootstyle="info", command=lambda u=acc.url: webbrowser.open_new_tab(u)).pack(side=tk.LEFT, padx=2) |
| 268 | + |
| 269 | + container_widget.configure(state="normal") |
| 270 | + container_widget.window_create("end", window=frame) |
| 271 | + container_widget.insert("end", "\n\n") |
| 272 | + container_widget.configure(state="disabled") |
| 273 | + |
| 274 | + # Async image loading |
| 275 | + def load_cover_async(): |
| 276 | + img = load_cover(acc.image_url) |
| 277 | + if img: |
| 278 | + def set_img(): |
| 279 | + if art_label.winfo_exists(): |
| 280 | + art_label.configure(image=img) |
| 281 | + art_label.image = img |
| 282 | + container_widget.after(0, set_img) |
| 283 | + threading.Thread(target=load_cover_async, daemon=True).start() |
| 284 | + |
| 285 | + # ---------------- PAGINATION ---------------- # |
| 286 | + def next_page(self): |
| 287 | + if self.current_page * RESULTS_PER_PAGE < len(self.results): |
| 288 | + self.current_page += 1 |
| 289 | + self.display_page() |
| 290 | + |
| 291 | + def prev_page(self): |
| 292 | + if self.current_page > 1: |
| 293 | + self.current_page -= 1 |
| 294 | + self.display_page() |
| 295 | + |
| 296 | + # ---------------- FAVORITES ---------------- # |
| 297 | + def show_favorites(self): |
| 298 | + fav_win = tb.Toplevel(self.app) |
| 299 | + fav_win.title("Favorites") |
| 300 | + fav_win.geometry("700x500") |
| 301 | + fav_text = ScrolledText(fav_win) |
| 302 | + fav_text.pack(fill=tk.BOTH, expand=True) |
| 303 | + for a in self.favorites: |
| 304 | + self.insert_accommodation(fav_text.text, a) |
| 305 | + fav_text.text.configure(state="disabled") |
| 306 | + |
| 307 | +# ---------------- RUN ---------------- # |
| 308 | +if __name__ == "__main__": |
| 309 | + AccommodationEngine() |
0 commit comments