|
| 1 | +import threading |
| 2 | +import webbrowser |
| 3 | +import tkinter as tk |
| 4 | +from tkinter import messagebox, simpledialog |
| 5 | +from urllib.parse import urlparse |
| 6 | +from dataclasses import dataclass |
| 7 | +from typing import List |
| 8 | + |
| 9 | +import ttkbootstrap as tb |
| 10 | +from ttkbootstrap.constants import * |
| 11 | +from ttkbootstrap.widgets.scrolled import ScrolledText |
| 12 | + |
| 13 | +from sklearn.feature_extraction.text import TfidfVectorizer |
| 14 | +from sklearn.metrics.pairwise import cosine_similarity |
| 15 | +from rank_bm25 import BM25Okapi |
| 16 | +from ddgs import DDGS |
| 17 | + |
| 18 | +# ---------------- CONFIG ---------------- # |
| 19 | +RESULTS_PER_PAGE = 6 # Can be changed via UI |
| 20 | + |
| 21 | +# ---------------- GLOBAL STATE ---------------- # |
| 22 | +all_ranked_results: List["SearchResult"] = [] |
| 23 | +current_page = 1 |
| 24 | +recent_queries: List[str] = [] |
| 25 | + |
| 26 | +# ---------------- DATA STRUCTURE ---------------- # |
| 27 | +@dataclass |
| 28 | +class SearchResult: |
| 29 | + title: str |
| 30 | + url: str |
| 31 | + display_url: str |
| 32 | + snippet: str |
| 33 | + |
| 34 | +# ---------------- URL HELPERS ---------------- # |
| 35 | +def short_display_url(url: str) -> str: |
| 36 | + parsed = urlparse(url) |
| 37 | + path = parsed.path.strip("/").split("/")[:5] |
| 38 | + if path and path[0]: |
| 39 | + return f"{parsed.netloc} › " + " › ".join(path) |
| 40 | + return parsed.netloc |
| 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 | + messagebox.showerror("Search Error", f"An error occurred: {str(e)}") |
| 59 | + return [] |
| 60 | + return results |
| 61 | + |
| 62 | +def rank_results(query: str, results: List[SearchResult]) -> List[tuple]: |
| 63 | + if not results: |
| 64 | + return [] |
| 65 | + |
| 66 | + docs = [f"{r.title} {r.snippet}" for r in results] |
| 67 | + vectorizer = TfidfVectorizer(stop_words="english") |
| 68 | + tfidf_matrix = vectorizer.fit_transform(docs + [query]) |
| 69 | + |
| 70 | + if tfidf_matrix.shape[0] <= 1: |
| 71 | + return [(res, 0) for res in results] |
| 72 | + |
| 73 | + tfidf_scores = cosine_similarity(tfidf_matrix[-1], tfidf_matrix[:-1]).flatten() |
| 74 | + bm25 = BM25Okapi([d.lower().split() for d in docs]) |
| 75 | + bm25_scores = bm25.get_scores(query.lower().split()) |
| 76 | + |
| 77 | + ranked = [] |
| 78 | + for i, res in enumerate(results): |
| 79 | + score = (tfidf_scores[i] + bm25_scores[i]) / 2 |
| 80 | + ranked.append((res, score)) |
| 81 | + |
| 82 | + return sorted(ranked, key=lambda x: x[1], reverse=True) |
| 83 | + |
| 84 | +# ---------------- UI HELPERS ---------------- # |
| 85 | +def open_url(url: str): |
| 86 | + if url: |
| 87 | + webbrowser.open_new_tab(url) |
| 88 | + |
| 89 | +def highlight_query_words(text_widget, query): |
| 90 | + for word in query.split(): |
| 91 | + start = "1.0" |
| 92 | + while True: |
| 93 | + pos = text_widget.search(word, start, stopindex="end", nocase=True) |
| 94 | + if not pos: |
| 95 | + break |
| 96 | + end_pos = f"{pos}+{len(word)}c" |
| 97 | + text_widget.tag_add("highlight", pos, end_pos) |
| 98 | + start = end_pos |
| 99 | + text_widget.tag_config("highlight", background="#fff59d") # yellow |
| 100 | + |
| 101 | +# ---------------- DISPLAY ---------------- # |
| 102 | +def display_page(): |
| 103 | + text.configure(state="normal") |
| 104 | + text.delete("1.0", "end") |
| 105 | + |
| 106 | + start = (current_page - 1) * RESULTS_PER_PAGE |
| 107 | + end = start + RESULTS_PER_PAGE |
| 108 | + page_results = all_ranked_results[start:end] |
| 109 | + |
| 110 | + if not page_results: |
| 111 | + text.insert("end", "No results found.\n") |
| 112 | + text.configure(state="disabled") |
| 113 | + update_pagination() |
| 114 | + return |
| 115 | + |
| 116 | + for idx, (res, _) in enumerate(page_results): |
| 117 | + # Title |
| 118 | + title_tag = f"title_{idx}" |
| 119 | + text.tag_config(title_tag, foreground="#1a0dab", font=("Segoe UI", 14, "bold"), spacing1=5) |
| 120 | + text.insert("end", res.title + "\n", title_tag) |
| 121 | + text.tag_bind(title_tag, "<Double-Button-1>", lambda e, url=res.url: open_url(url)) |
| 122 | + text.tag_bind(title_tag, "<Enter>", lambda e: text.config(cursor="hand2")) |
| 123 | + text.tag_bind(title_tag, "<Leave>", lambda e: text.config(cursor="")) |
| 124 | + |
| 125 | + # URL |
| 126 | + url_tag = f"url_{idx}" |
| 127 | + text.tag_config(url_tag, foreground="#188038", font=("Segoe UI", 11, "italic")) |
| 128 | + text.insert("end", res.display_url + "\n", url_tag) |
| 129 | + |
| 130 | + # Snippet |
| 131 | + snippet_tag = f"snippet_{idx}" |
| 132 | + text.tag_config(snippet_tag, foreground="#4d5156", font=("Segoe UI", 12), spacing3=10) |
| 133 | + text.insert("end", res.snippet + "\n\n", snippet_tag) |
| 134 | + |
| 135 | + # Highlight query words |
| 136 | + highlight_query_words(text, query_entry.get()) |
| 137 | + |
| 138 | + text.configure(state="disabled") |
| 139 | + text.yview_moveto(0) |
| 140 | + update_pagination() |
| 141 | + |
| 142 | +# ---------------- PAGINATION ---------------- # |
| 143 | +def next_page(): |
| 144 | + global current_page |
| 145 | + total_pages = max(1, (len(all_ranked_results) - 1) // RESULTS_PER_PAGE + 1) |
| 146 | + if current_page < total_pages: |
| 147 | + current_page += 1 |
| 148 | + display_page() |
| 149 | + |
| 150 | +def prev_page(): |
| 151 | + global current_page |
| 152 | + if current_page > 1: |
| 153 | + current_page -= 1 |
| 154 | + display_page() |
| 155 | + |
| 156 | +def update_pagination(): |
| 157 | + total_pages = max(1, (len(all_ranked_results) - 1) // RESULTS_PER_PAGE + 1) |
| 158 | + page_label.config(text=f"Page {current_page} of {total_pages}") |
| 159 | + prev_btn.config(state=DISABLED if current_page == 1 else NORMAL) |
| 160 | + next_btn.config(state=DISABLED if current_page >= total_pages else NORMAL) |
| 161 | + |
| 162 | +# ---------------- SEARCH ---------------- # |
| 163 | +def perform_search(): |
| 164 | + query = query_entry.get().strip() |
| 165 | + if not query: |
| 166 | + messagebox.showwarning("Input Required", "Enter a search query.") |
| 167 | + return |
| 168 | + if query not in recent_queries: |
| 169 | + recent_queries.append(query) |
| 170 | + threading.Thread(target=search_thread, args=(query,), daemon=True).start() |
| 171 | + |
| 172 | +def search_thread(query): |
| 173 | + global all_ranked_results, current_page |
| 174 | + current_page = 1 |
| 175 | + update_text(lambda: text.insert("end", "Searching...\n")) |
| 176 | + |
| 177 | + results = fetch_search_results(query) |
| 178 | + all_ranked_results = rank_results(query, results) |
| 179 | + display_page() |
| 180 | + |
| 181 | +def update_text(callback): |
| 182 | + text.configure(state="normal") |
| 183 | + callback() |
| 184 | + text.configure(state="disabled") |
| 185 | + |
| 186 | +# ---------------- UI SETUP ---------------- # |
| 187 | +app = tb.Window(title="Search Ranking App", themename="flatly", size=(980,700), resizable=(True,True)) |
| 188 | + |
| 189 | +top = tb.Frame(app, padding=15) |
| 190 | +top.pack(fill=X) |
| 191 | + |
| 192 | +tb.Label(top, text="Search", font=("Segoe UI", 16, "bold")).pack(anchor=W) |
| 193 | + |
| 194 | +query_entry = tb.Entry(top, font=("Segoe UI",12)) |
| 195 | +query_entry.pack(fill=X, pady=8) |
| 196 | +query_entry.bind("<Return>", lambda e: perform_search()) |
| 197 | + |
| 198 | +tb.Button(top, text="Search", bootstyle="primary", command=perform_search).pack(anchor=E) |
| 199 | + |
| 200 | +# Results |
| 201 | +result_frame = tb.Frame(app, padding=(15,5)) |
| 202 | +result_frame.pack(fill=BOTH, expand=True) |
| 203 | + |
| 204 | +result_box = ScrolledText(result_frame, autohide=True) |
| 205 | +result_box.pack(fill=BOTH, expand=True) |
| 206 | + |
| 207 | +text = result_box.text |
| 208 | +text.configure(state="disabled", wrap="word") |
| 209 | + |
| 210 | +# Pagination |
| 211 | +nav = tb.Frame(app, padding=10) |
| 212 | +nav.pack(fill=X) |
| 213 | + |
| 214 | +prev_btn = tb.Button(nav, text="← Prev", bootstyle="secondary", command=prev_page) |
| 215 | +prev_btn.pack(side=LEFT) |
| 216 | + |
| 217 | +page_label = tb.Label(nav, text="Page 1", font=("Segoe UI",10)) |
| 218 | +page_label.pack(side=LEFT, padx=10) |
| 219 | + |
| 220 | +next_btn = tb.Button(nav, text="Next →", bootstyle="secondary", command=next_page) |
| 221 | +next_btn.pack(side=LEFT) |
| 222 | + |
| 223 | +# ---------------- RUN ---------------- # |
| 224 | +app.mainloop() |
0 commit comments