shDima1 commited on
Commit
7ff573a
·
verified ·
1 Parent(s): 23e4ec0

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +225 -309
src/streamlit_app.py CHANGED
@@ -1,327 +1,243 @@
 
1
  import random
2
- import time
 
 
3
  from urllib.parse import urlparse
 
4
 
5
- import streamlit as st
6
-
7
- # Google search (неофициальная библиотека)
8
- try:
9
- from googlesearch import search as google_search
10
- except ImportError:
11
- google_search = None
12
-
13
- # DuckDuckGo search
14
- try:
15
- from duckduckgo_search import DDGS
16
- except ImportError:
17
- DDGS = None
18
-
19
- # ------------------ НАСТРОЙКИ UI ------------------
20
-
21
  st.set_page_config(
22
- page_title="Hybrid Search",
23
  page_icon="🔎",
24
  layout="wide",
 
25
  )
26
 
27
- # Кастомные стили
28
- st.markdown(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  """
30
- <style>
31
- body {
32
- background-color: #070B12;
33
- color: #f5f5f5;
34
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
35
- }
36
- .main {
37
- padding-top: 2rem;
38
- }
39
- .search-title {
40
- font-size: 2.4rem;
41
- font-weight: 700;
42
- background: linear-gradient(90deg, #7f5dff, #ff6bcb);
43
- -webkit-background-clip: text;
44
- color: transparent;
45
- text-align: center;
46
- margin-bottom: 0.3rem;
47
- }
48
- .search-subtitle {
49
- text-align: center;
50
- font-size: 0.95rem;
51
- color: #9ca3af;
52
- margin-bottom: 1.8rem;
53
- }
54
- .stTextInput>div>div>input {
55
- background: radial-gradient(circle at top left, #111827, #020817);
56
- border-radius: 999px;
57
- border: 1px solid #374151;
58
- padding: 0.8rem 1.1rem;
59
- color: #e5e7eb;
60
- font-size: 0.95rem;
61
- }
62
- .stTextInput>div>div>input:focus {
63
- border-color: #7f5dff;
64
- box-shadow: 0 0 0 1px #7f5dff44;
65
- }
66
- .stButton>button {
67
- border-radius: 999px;
68
- padding: 0.45rem 1.3rem;
69
- background: linear-gradient(90deg, #7f5dff, #ff6bcb);
70
- border: none;
71
- color: white;
72
- font-weight: 600;
73
- font-size: 0.9rem;
74
- cursor: pointer;
75
- }
76
- .stButton>button:hover {
77
- opacity: 0.93;
78
- box-shadow: 0 6px 18px rgba(127,93,255,0.25);
79
- }
80
- .pill {
81
- display: inline-flex;
82
- align-items: center;
83
- gap: 0.22rem;
84
- padding: 0.15rem 0.55rem;
85
- border-radius: 999px;
86
- font-size: 0.64rem;
87
- background-color: #111827;
88
- color: #9ca3af;
89
- border: 1px solid #1f2937;
90
- margin-right: 0.2rem;
91
- }
92
- .source-google { color: #22c55e; }
93
- .source-duck { color: #f97316; }
94
- .card {
95
- border-radius: 16px;
96
- padding: 0.75rem 0.9rem;
97
- background: radial-gradient(circle at top left, #020817, #020817);
98
- border: 1px solid #111827;
99
- margin-bottom: 0.5rem;
100
- transition: all 0.18s ease;
101
- }
102
- .card:hover {
103
- border-color: #7f5dff55;
104
- box-shadow: 0 5px 14px rgba(0,0,0,0.55);
105
- transform: translateY(-1px);
106
- }
107
- .card-title {
108
- font-size: 0.92rem;
109
- font-weight: 600;
110
- color: #e5e7eb;
111
- margin-bottom: 0.15rem;
112
- }
113
- .card-url {
114
- font-size: 0.7rem;
115
- color: #64748b;
116
- margin-bottom: 0.25rem;
117
- word-break: break-all;
118
- }
119
- .card-snippet {
120
- font-size: 0.78rem;
121
- color: #9ca3af;
122
- }
123
- .provider-label {
124
- font-size: 0.65rem;
125
- font-weight: 500;
126
- opacity: 0.9;
127
- }
128
- .tag-mixed {
129
- font-size: 0.64rem;
130
- color: #818cf8;
131
- }
132
- </style>
133
- """,
134
- unsafe_allow_html=True
135
- )
136
-
137
- # ------------------ ФУНКЦИИ ПОИСКА ------------------
138
-
139
 
140
- def search_google(query, num_results=10):
141
- if google_search is None:
142
- return []
 
 
 
 
143
  try:
144
- urls = list(
145
- google_search(
146
- query,
147
- num=num_results,
148
- stop=num_results,
149
- lang="en",
150
- )
151
- )
152
- results = []
153
- for u in urls:
154
- results.append(
155
- {
156
- "title": u,
157
- "url": u,
158
- "snippet": "",
159
- "source": "google",
160
- }
161
- )
162
- return results
163
  except Exception as e:
164
- st.warning(f"Ошибка Google-поиска: {e}")
165
- return []
166
 
167
-
168
- def search_duckduckgo(query, num_results=10):
169
- if DDGS is None:
170
- return []
 
 
 
171
  try:
172
- results = []
173
  with DDGS() as ddgs:
174
- for r in ddgs.text(query, max_results=num_results):
175
- results.append(
176
- {
177
- "title": r.get("title") or r.get("href") or "",
178
- "url": r.get("href") or "",
179
- "snippet": r.get("body") or "",
180
- "source": "duckduckgo",
181
- }
182
- )
183
- return results
184
  except Exception as e:
185
- st.warning(f"Ошибка DuckDuckGo-поиска: {e}")
186
- return []
187
-
188
-
189
- def normalize_url(u: str) -> str:
190
- if not u:
191
- return ""
192
- try:
193
- p = urlparse(u)
194
- return f"{p.scheme}://{p.netloc}{p.path}"
195
- except Exception:
196
- return u.strip()
197
-
198
 
199
- def merge_and_shuffle(google_results, duck_results, max_total=20):
200
- # Убираем пустые и приводим URL
201
- seen = set()
202
- merged = []
203
-
204
- def add_unique(item):
205
- url_norm = normalize_url(item.get("url", ""))
206
- if not url_norm:
207
- return
208
- key = url_norm.lower()
209
- if key in seen:
210
- return
211
- seen.add(key)
212
- clean_item = {
213
- "title": item.get("title") or url_norm,
214
- "url": url_norm,
215
- "snippet": item.get("snippet", ""),
216
- "source": item.get("source", ""),
217
- }
218
- merged.append(clean_item)
219
-
220
- for r in google_results:
221
- add_unique(r)
222
- for r in duck_results:
223
- add_unique(r)
224
-
225
- random.shuffle(merged)
226
-
227
- return merged[:max_total]
228
-
229
-
230
- # ------------------ UI ------------------
231
-
232
- st.markdown('<div class="search-title">Hybrid Search</div>', unsafe_allow_html=True)
233
- st.markdown(
234
- '<div class="search-subtitle">Поиск по Google + DuckDuckGo с аккуратным смешиванием результатов.</div>',
235
- unsafe_allow_html=True,
236
- )
237
-
238
- with st.container():
239
- col1, col2 = st.columns([6, 1.7])
240
- with col1:
241
- query = st.text_input(
242
- "",
243
- value="",
244
- placeholder="Введите запрос (например, \"latest AI research tools\" или \"новости ИИ\")",
245
- )
246
- with col2:
247
- search_clicked = st.button("🔎 Найти", use_container_width=True)
248
-
249
- # Авто-запуск по Enter
250
- run_search = search_clicked or (query.strip() != "" and "auto_ran" not in st.session_state)
251
- if run_search and query.strip():
252
- st.session_state["auto_ran"] = True
253
-
254
- if run_search and query.strip():
255
- with st.spinner("Ищем в Google и DuckDuckGo..."):
256
- start_time = time.time()
257
- g_results = search_google(query, num_results=10)
258
- d_results = search_duckduckgo(query, num_results=10)
259
- combined = merge_and_shuffle(g_results, d_results, max_total=20)
260
- elapsed = time.time() - start_time
261
-
262
- # Инфо о состоянии
263
- c1, c2, c3, c4 = st.columns([2, 2, 2, 3])
264
- with c1:
265
- st.markdown(
266
- f'<div class="pill"><span class="provider-label">Google</span> <span class="source-google">{len(g_results)}</span></div>',
267
- unsafe_allow_html=True,
268
- )
269
- with c2:
270
- st.markdown(
271
- f'<div class="pill"><span class="provider-label">DuckDuckGo</span> <span class="source-duck">{len(d_results)}</span></div>',
272
- unsafe_allow_html=True,
273
- )
274
- with c3:
275
- st.markdown(
276
- f'<div class="pill"><span class="provider-label">Mixed total</span> <span class="tag-mixed">{len(combined)}</span></div>',
277
- unsafe_allow_html=True,
278
- )
279
- with c4:
280
- st.markdown(
281
- f'<div class="pill"><span class="provider-label">Time</span> {elapsed:.2f}s</div>',
282
- unsafe_allow_html=True,
283
- )
284
-
285
- st.markdown("---")
286
-
287
- if not combined:
288
- st.info("Ничего не смогли найти или возникла ошибка у обоих источников.")
289
- else:
290
- for item in combined:
291
- src = item.get("source", "")
292
- if src == "google":
293
- src_label = '<span class="source-google">Google</span>'
294
- elif src == "duckduckgo":
295
- src_label = '<span class="source-duck">DuckDuckGo</span>'
296
- else:
297
- src_label = '<span class="tag-mixed">Unknown</span>'
298
-
299
- title = item.get("title") or item["url"]
300
- url = item["url"]
301
- snippet = item.get("snippet") or ""
302
-
303
- st.markdown(
304
- f"""
305
- <div class="card">
306
- <div class="card-title">
307
- <a href="{url}" target="_blank" style="color:#e5e7eb;text-decoration:none;">
308
- {title}
309
- </a>
310
- </div>
311
- <div class="card-url">{url}</div>
312
- <div class="card-snippet">{snippet}</div>
313
- <div class="provider-label">Источник: {src_label}</div>
314
- </div>
315
- """,
316
- unsafe_allow_html=True,
317
- )
318
-
319
- else:
320
- st.markdown(
321
  """
322
- <div style="text-align:center; margin-top:1rem; color:#6b7280; font-size:0.8rem;">
323
- Введите запрос и нажмите «Найти». Результаты будут рандомно перемешаны между Google и DuckDuckGo.
324
- </div>
325
- """,
326
- unsafe_allow_html=True,
327
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
  import random
3
+ from googlesearch import search
4
+ from duckduckgo_search import DDGS
5
+ from concurrent.futures import ThreadPoolExecutor
6
  from urllib.parse import urlparse
7
+ from typing import List, Dict, Any
8
 
9
+ # --- Конфигурация страницы Streamlit ---
10
+ # Устанавливаем заголовок, иконку и макет страницы.
11
+ # 'wide' макет использует всю ширину экрана.
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  st.set_page_config(
13
+ page_title="Meta-Поисковик",
14
  page_icon="🔎",
15
  layout="wide",
16
+ initial_sidebar_state="auto"
17
  )
18
 
19
+ # --- Кастомный CSS для красивого оформления ---
20
+ # Внедряем CSS для стилизации карточек результатов, чтобы они выглядели современно.
21
+ def local_css():
22
+ st.markdown("""
23
+ <style>
24
+ /* Общий контейнер для карточки результата */
25
+ .result-card {
26
+ border: 1px solid #e1e4e8;
27
+ border-radius: 12px;
28
+ padding: 20px;
29
+ margin-bottom: 20px;
30
+ background-color: #ffffff;
31
+ box-shadow: 0 4px 8px rgba(0,0,0,0.05);
32
+ transition: box-shadow 0.3s ease-in-out, transform 0.3s ease-in-out;
33
+ }
34
+ .result-card:hover {
35
+ box-shadow: 0 8px 16px rgba(0,0,0,0.1);
36
+ transform: translateY(-3px);
37
+ }
38
+ /* Заголовок-ссылка */
39
+ .result-title a {
40
+ color: #0d6efd !important; /* Синий цвет для ссылок */
41
+ font-size: 1.25em;
42
+ font-weight: 600;
43
+ text-decoration: none;
44
+ display: block;
45
+ margin-bottom: 5px;
46
+ }
47
+ .result-title a:hover {
48
+ text-decoration: underline;
49
+ }
50
+ /* URL сайта */
51
+ .result-link {
52
+ color: #586069;
53
+ font-size: 0.9em;
54
+ margin-bottom: 10px;
55
+ word-break: break-all; /* Перенос длинных ссылок */
56
+ }
57
+ /* Описание/сниппет */
58
+ .result-description {
59
+ color: #24292e;
60
+ font-size: 1em;
61
+ }
62
+ /* Источник (Google/DuckDuckGo) */
63
+ .result-source {
64
+ font-size: 0.8em;
65
+ color: #6a737d;
66
+ text-align: right;
67
+ margin-top: 15px;
68
+ font-weight: 500;
69
+ }
70
+ /* Стиль для фавиконок */
71
+ .favicon {
72
+ width: 16px;
73
+ height: 16px;
74
+ margin-right: 8px;
75
+ vertical-align: middle;
76
+ }
77
+ </style>
78
+ """, unsafe_allow_html=True)
79
+
80
+ # Применяем CSS стили
81
+ local_css()
82
+
83
+ # --- Функции для поиска ---
84
+
85
+ def get_favicon_url(url: str) -> str:
86
  """
87
+ Получает URL фавиконки для сайта, используя сервис Google.
88
+ Это делает результаты визуально более привлекательными.
89
+ """
90
+ try:
91
+ domain = urlparse(url).netloc
92
+ if domain:
93
+ return f"https://www.google.com/s2/favicons?sz=64&domain_url={domain}"
94
+ except Exception:
95
+ pass
96
+ # Возвращаем заглушку, если не удалось получить домен
97
+ return "https://i.imgur.com/t3oEtA2.png" # Иконка глобуса по умолчанию
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ def search_google(query: str, num_results: int) -> List[Dict[str, str]]:
100
+ """
101
+ Выполняет поиск в Google и форматирует результаты.
102
+ Использует try-except для обработки возможных ошибок сети или блокировок.
103
+ """
104
+ st.write(f"🔍 Запускаю поиск в Google для '{query}'...")
105
+ results = []
106
  try:
107
+ # Библиотека googlesearch является генератором, поэтому мы его итерируем
108
+ search_results = search(query, num_results=num_results, lang="ru")
109
+ for i, url in enumerate(search_results):
110
+ # В этой библиотеке нет готовых сниппетов, поэтому мы их пропускаем.
111
+ # Для более сложных решений потребовался бы скрейпинг, что выходит за рамки.
112
+ parsed_url = urlparse(url)
113
+ title = parsed_url.path.split('/')[-1].replace('-', ' ').replace('_', ' ') or parsed_url.netloc
114
+ results.append({
115
+ "title": title.capitalize(),
116
+ "link": url,
117
+ "description": f"Результат из Google. Для получения описания требуется дополнительный парсинг страницы.",
118
+ "source": "Google"
119
+ })
120
+ if i + 1 >= num_results:
121
+ break
 
 
 
 
122
  except Exception as e:
123
+ st.error(f"⚠️ Ошибка при поиске в Google: {e}")
124
+ return results
125
 
126
+ def search_duckduckgo(query: str, num_results: int) -> List[Dict[str, str]]:
127
+ """
128
+ Выполняет поиск в DuckDuckGo и форматирует результаты.
129
+ Эта библиотека возвращает более структурированные данные, включая описание.
130
+ """
131
+ st.write(f"🦆 Запускаю поиск в DuckDuckGo для '{query}'...")
132
+ results = []
133
  try:
 
134
  with DDGS() as ddgs:
135
+ # max_results контролирует количество получаемых результатов
136
+ search_results = ddgs.text(query, region='ru-ru', max_results=num_results)
137
+ for r in search_results:
138
+ results.append({
139
+ "title": r.get('title', ''),
140
+ "link": r.get('href', ''),
141
+ "description": r.get('body', ''),
142
+ "source": "DuckDuckGo"
143
+ })
 
144
  except Exception as e:
145
+ st.error(f"⚠️ Ошибка при поиске в DuckDuckGo: {e}")
146
+ return results
 
 
 
 
 
 
 
 
 
 
 
147
 
148
+ def display_results(results: List[Dict[str, Any]]):
149
+ """
150
+ Отображает отформатированные и стилизованные результаты поиска.
151
+ """
152
+ if not results:
153
+ st.warning("Не удалось найти результаты по вашему запросу. Попробуйте изменить его.")
154
+ return
155
+
156
+ st.subheader(f"Найдено результатов: {len(results)}")
157
+
158
+ # Разделяем на две колонки для более компактного вида на широких экранах
159
+ col1, col2 = st.columns(2)
160
+
161
+ for i, res in enumerate(results):
162
+ favicon = get_favicon_url(res["link"])
163
+ # Отображаем карточку с результатом
164
+ card_html = f"""
165
+ <div class="result-card">
166
+ <h3 class="result-title">
167
+ <img src="{favicon}" class="favicon">
168
+ <a href="{res['link']}" target="_blank">{res['title']}</a>
169
+ </h3>
170
+ <div class="result-link">{res['link']}</div>
171
+ <p class="result-description">{res['description']}</p>
172
+ <div class="result-source">Источник: <strong>{res['source']}</strong></div>
173
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  """
175
+ # Распределяем результаты по колонкам
176
+ if i % 2 == 0:
177
+ with col1:
178
+ st.markdown(card_html, unsafe_allow_html=True)
179
+ else:
180
+ with col2:
181
+ st.markdown(card_html, unsafe_allow_html=True)
182
+
183
+ # --- Основная часть приложения ---
184
+
185
+ # Заголовок и описание
186
+ st.title("🔎 Meta-Поисковик")
187
+ st.markdown("Этот поисковик объединяет и перемешивает результаты из **Google** и **DuckDuckGo**.")
188
+
189
+ # Боковая панель с настройками
190
+ with st.sidebar:
191
+ st.header("⚙️ Настройки поиска")
192
+ num_results_per_engine = st.slider(
193
+ "Количество результатов от каждого поисковика:",
194
+ min_value=5,
195
+ max_value=25,
196
+ value=10,
197
+ step=1,
198
+ help="Выберите, сколько ссылок запросить у Google и DuckDuckGo."
199
+ )
200
+
201
+ # Форма для ввода поискового запроса
202
+ with st.form(key="search_form"):
203
+ search_query = st.text_input(
204
+ "Введите ваш запрос",
205
+ placeholder="Например: лучшие фреймворки для Python",
206
+ key="search_input"
207
+ )
208
+ submit_button = st.form_submit_button(label="🚀 Найти!")
209
+
210
+ # Логика обработки нажатия кнопки
211
+ if submit_button and search_query:
212
+ # Используем `st.spinner` для отображения индикатора загрузки во время поиска
213
+ with st.spinner(f"Ищем '{search_query}'... Это может занять несколько секунд."):
214
+ all_results = []
215
+ # Запускаем поиск в двух потоках для ускорения процесса
216
+ with ThreadPoolExecutor(max_workers=2) as executor:
217
+ # Отправляем задачи на выполнение
218
+ google_future = executor.submit(search_google, search_query, num_results_per_engine)
219
+ ddg_future = executor.submit(search_duckduckgo, search_query, num_results_per_engine)
220
+
221
+ # Получаем результаты по мере их готовности
222
+ google_results = google_future.result()
223
+ ddg_results = ddg_future.result()
224
+
225
+ # Объединяем результаты из обоих источников
226
+ all_results.extend(google_results)
227
+ all_results.extend(ddg_results)
228
+
229
+ # Перемешиваем результаты для "честной" выдачи
230
+ random.shuffle(all_results)
231
+
232
+ # Сохраняем результаты в состоянии сессии, чтобы они н�� пропадали при взаимодействии с другими элементами
233
+ st.session_state['search_results'] = all_results
234
+ st.session_state['last_query'] = search_query
235
+
236
+ # Отображение результатов, если они есть в состоянии сессии
237
+ if 'search_results' in st.session_state:
238
+ st.markdown("---") # Горизонтальная линия-разделитель
239
+ display_results(st.session_state['search_results'])
240
+
241
+ # Начальная инструкция для пользователя
242
+ else:
243
+ st.info("Введите поисковый запрос в поле выше и нажмите 'Найти!', чтобы начать.")