|
| 1 | +import threading |
| 2 | +import webbrowser |
| 3 | +import tkinter as tk |
| 4 | +from urllib.parse import urlparse |
| 5 | +from dataclasses import dataclass |
| 6 | +from typing import List |
| 7 | + |
| 8 | +import ttkbootstrap as tb |
| 9 | +from ttkbootstrap.constants import * |
| 10 | + |
| 11 | +from sklearn.feature_extraction.text import TfidfVectorizer |
| 12 | +from sklearn.metrics.pairwise import cosine_similarity |
| 13 | +from rank_bm25 import BM25Okapi |
| 14 | +from ddgs import DDGS |
| 15 | + |
| 16 | +# ---------------- CONFIG ---------------- # |
| 17 | +RESULTS_PER_PAGE = 5 |
| 18 | + |
| 19 | +# ---------------- GLOBAL STATE ---------------- # |
| 20 | +all_ranked_results: List["SearchResult"] = [] |
| 21 | +current_page = 1 |
| 22 | + |
| 23 | +# ---------------- DATA STRUCTURE ---------------- # |
| 24 | +@dataclass |
| 25 | +class SearchResult: |
| 26 | + title: str |
| 27 | + url: str |
| 28 | + display_url: str |
| 29 | + snippet: str |
| 30 | + |
| 31 | +# ---------------- HELPERS ---------------- # |
| 32 | +def short_display_url(url: str) -> str: |
| 33 | + parsed = urlparse(url) |
| 34 | + path = parsed.path.strip("/").split("/")[:5] |
| 35 | + if path and path[0]: |
| 36 | + return f"{parsed.netloc} › " + " › ".join(path) |
| 37 | + return parsed.netloc |
| 38 | + |
| 39 | +def open_url(url: str): |
| 40 | + webbrowser.open_new_tab(url) |
| 41 | + |
| 42 | +# ---------------- SEARCH LOGIC ---------------- # |
| 43 | +def fetch_search_results(query: str) -> List[SearchResult]: |
| 44 | + results = [] |
| 45 | + try: |
| 46 | + with DDGS() as ddgs: |
| 47 | + for entry in ddgs.text(query, max_results=25): |
| 48 | + title = entry.get("title") or "Untitled" |
| 49 | + url = entry.get("href") or "" |
| 50 | + snippet = entry.get("body") or "No description available." |
| 51 | + results.append(SearchResult( |
| 52 | + title=title, |
| 53 | + url=url, |
| 54 | + display_url=short_display_url(url), |
| 55 | + snippet=snippet |
| 56 | + )) |
| 57 | + except Exception as e: |
| 58 | + tb.Messagebox.show_error("Search Error", str(e)) |
| 59 | + return results |
| 60 | + |
| 61 | +def rank_results(query: str, results: List[SearchResult]) -> List[tuple]: |
| 62 | + if not results: |
| 63 | + return [] |
| 64 | + docs = [f"{r.title} {r.snippet}" for r in results] |
| 65 | + vectorizer = TfidfVectorizer(stop_words="english") |
| 66 | + tfidf_matrix = vectorizer.fit_transform(docs + [query]) |
| 67 | + tfidf_scores = cosine_similarity(tfidf_matrix[-1], tfidf_matrix[:-1]).flatten() |
| 68 | + bm25 = BM25Okapi([d.lower().split() for d in docs]) |
| 69 | + bm25_scores = bm25.get_scores(query.lower().split()) |
| 70 | + ranked = [] |
| 71 | + for i, res in enumerate(results): |
| 72 | + score = (tfidf_scores[i] + bm25_scores[i]) / 2 |
| 73 | + ranked.append((res, score)) |
| 74 | + return sorted(ranked, key=lambda x: x[1], reverse=True) |
| 75 | + |
| 76 | +# ---------------- DISPLAY ---------------- # |
| 77 | +def display_page(): |
| 78 | + for widget in results_frame.winfo_children(): |
| 79 | + widget.destroy() |
| 80 | + |
| 81 | + start = (current_page - 1) * RESULTS_PER_PAGE |
| 82 | + end = start + RESULTS_PER_PAGE |
| 83 | + page_results = all_ranked_results[start:end] |
| 84 | + |
| 85 | + if not page_results: |
| 86 | + tb.Label( |
| 87 | + results_frame, |
| 88 | + text="No results found.", |
| 89 | + font=("Segoe UI", 12), |
| 90 | + foreground="#333333" |
| 91 | + ).pack(pady=10) |
| 92 | + return |
| 93 | + |
| 94 | + for res, score in page_results: |
| 95 | + # Frame without extra background, inherits parent |
| 96 | + card = tb.Frame(results_frame, padding=15) |
| 97 | + card.pack(fill=X, pady=8, padx=5) |
| 98 | + |
| 99 | + # Title (clickable with hover effect) |
| 100 | + title_label = tb.Label( |
| 101 | + card, |
| 102 | + text=res.title, |
| 103 | + font=("Segoe UI", 14, "bold"), |
| 104 | + cursor="hand2", |
| 105 | + foreground="#1a0dab" |
| 106 | + ) |
| 107 | + title_label.pack(anchor=W) |
| 108 | + title_label.bind("<Button-1>", lambda e, url=res.url: open_url(url)) |
| 109 | + title_label.bind("<Enter>", lambda e: title_label.configure(foreground="#0b3d91")) |
| 110 | + title_label.bind("<Leave>", lambda e: title_label.configure(foreground="#1a0dab")) |
| 111 | + |
| 112 | + # URL / Domain |
| 113 | + tb.Label( |
| 114 | + card, |
| 115 | + text=res.display_url, |
| 116 | + font=("Segoe UI", 10, "italic"), |
| 117 | + foreground="#006400" |
| 118 | + ).pack(anchor=W, pady=(2,5)) |
| 119 | + |
| 120 | + # Snippet with ellipsis |
| 121 | + snippet_text = res.snippet |
| 122 | + if len(snippet_text) > 250: |
| 123 | + snippet_text = snippet_text[:247] + "..." |
| 124 | + tb.Label( |
| 125 | + card, |
| 126 | + text=snippet_text, |
| 127 | + font=("Segoe UI", 12), |
| 128 | + wraplength=900, |
| 129 | + foreground="#333333" |
| 130 | + ).pack(anchor=W) |
| 131 | + |
| 132 | +# ---------------- PAGINATION ---------------- # |
| 133 | +def next_page(): |
| 134 | + global current_page |
| 135 | + if current_page * RESULTS_PER_PAGE < len(all_ranked_results): |
| 136 | + current_page += 1 |
| 137 | + display_page() |
| 138 | + update_pagination() |
| 139 | + |
| 140 | +def prev_page(): |
| 141 | + global current_page |
| 142 | + if current_page > 1: |
| 143 | + current_page -= 1 |
| 144 | + display_page() |
| 145 | + update_pagination() |
| 146 | + |
| 147 | +def update_pagination(): |
| 148 | + total = max(1, (len(all_ranked_results) - 1) // RESULTS_PER_PAGE + 1) |
| 149 | + page_label.config(text=f"Page {current_page} of {total}") |
| 150 | + prev_btn.config(state=DISABLED if current_page == 1 else NORMAL) |
| 151 | + next_btn.config(state=DISABLED if current_page == total else NORMAL) |
| 152 | + |
| 153 | +# ---------------- SEARCH ---------------- # |
| 154 | +def perform_search(): |
| 155 | + query = query_entry.get().strip() |
| 156 | + if not query: |
| 157 | + tb.Messagebox.show_warning("Input Required", "Enter a search query.") |
| 158 | + return |
| 159 | + |
| 160 | + # Disable search button and show "Searching..." text |
| 161 | + search_button.config(state=DISABLED) |
| 162 | + for widget in results_frame.winfo_children(): |
| 163 | + widget.destroy() |
| 164 | + tb.Label(results_frame, text="Searching...", font=("Segoe UI", 12)).pack(pady=8, padx=5) |
| 165 | + |
| 166 | + # Start the search in a separate thread |
| 167 | + threading.Thread(target=search_thread, args=(query,), daemon=True).start() |
| 168 | + |
| 169 | + |
| 170 | +def search_thread(query): |
| 171 | + global all_ranked_results, current_page |
| 172 | + |
| 173 | + try: |
| 174 | + # Fetch and rank results |
| 175 | + results = fetch_search_results(query) |
| 176 | + all_ranked_results = rank_results(query, results) |
| 177 | + current_page = 1 |
| 178 | + |
| 179 | + # Schedule the UI update on the main thread |
| 180 | + app.after(0, lambda: ( |
| 181 | + display_page(), |
| 182 | + update_pagination(), |
| 183 | + search_button.config(state=NORMAL) # Re-enable search button |
| 184 | + )) |
| 185 | + |
| 186 | + except Exception as e: |
| 187 | + # Show error on the main thread |
| 188 | + app.after(0, lambda: ( |
| 189 | + tb.Messagebox.show_error("Search Error", str(e)), |
| 190 | + search_button.config(state=NORMAL) |
| 191 | + )) |
| 192 | + |
| 193 | +# ---------------- UI ---------------- # |
| 194 | +app = tb.Window(title="FutureSearch Engine", themename="flatly", size=(980, 720)) |
| 195 | + |
| 196 | +# Top search frame |
| 197 | +top_frame = tb.Frame(app, padding=15) |
| 198 | +top_frame.pack(fill=X) |
| 199 | + |
| 200 | +tb.Label(top_frame, text="🌟 FutureSearch Engine", font=("Segoe UI", 18, "bold")).pack(anchor=W) |
| 201 | +query_entry = tb.Entry(top_frame, font=("Segoe UI", 12)) |
| 202 | +query_entry.pack(fill=X, pady=8) |
| 203 | +query_entry.bind("<Return>", lambda e: perform_search()) |
| 204 | +# Assign the search button to a variable |
| 205 | +search_button = tb.Button(top_frame, text="Search", bootstyle="success", command=perform_search) |
| 206 | +search_button.pack(anchor=E) |
| 207 | + |
| 208 | + |
| 209 | +# Scrollable results |
| 210 | +results_container = tb.Frame(app) |
| 211 | +results_container.pack(fill=BOTH, expand=True) |
| 212 | + |
| 213 | +canvas = tk.Canvas(results_container, highlightthickness=0) |
| 214 | +scrollbar = tb.Scrollbar(results_container, orient="vertical", command=canvas.yview) |
| 215 | +results_frame = tb.Frame(canvas) |
| 216 | + |
| 217 | +results_frame.bind( |
| 218 | + "<Configure>", |
| 219 | + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) |
| 220 | +) |
| 221 | + |
| 222 | +canvas.create_window((0, 0), window=results_frame, anchor="nw") |
| 223 | +canvas.configure(yscrollcommand=scrollbar.set) |
| 224 | + |
| 225 | +canvas.pack(side="left", fill="both", expand=True) |
| 226 | +scrollbar.pack(side="right", fill="y") |
| 227 | + |
| 228 | +# Pagination |
| 229 | +nav_frame = tb.Frame(app, padding=10) |
| 230 | +nav_frame.pack(fill=X) |
| 231 | +prev_btn = tb.Button(nav_frame, text="← Prev", bootstyle="secondary", command=prev_page) |
| 232 | +prev_btn.pack(side=LEFT) |
| 233 | +page_label = tb.Label(nav_frame, text="Page 1", font=("Segoe UI", 10)) |
| 234 | +page_label.pack(side=LEFT, padx=10) |
| 235 | +next_btn = tb.Button(nav_frame, text="Next →", bootstyle="secondary", command=next_page) |
| 236 | +next_btn.pack(side=LEFT) |
| 237 | + |
| 238 | +app.mainloop() |
0 commit comments