akra35567 commited on
Commit
6c76e16
·
verified ·
1 Parent(s): 95413a0

Upload web_search.py

Browse files
Files changed (1) hide show
  1. web_search.py +984 -0
web_search.py ADDED
@@ -0,0 +1,984 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # type: ignore
2
+ """
3
+ modules/web_search.py
4
+ ================================================================================
5
+ WEB SEARCH MÓDULO - BUSCA AUTÔNOMA COMPLETA E PROFISSIONAL
6
+ ================================================================================
7
+ Versão 3.0 - Motor de busca autônomo e inteligente
8
+
9
+ Features:
10
+ - DuckDuckGo via biblioteca `ddgs` (production-ready, sem scraping frágil)
11
+ - Busca de Texto, Notícias, Imagens e Vídeos (multi-tipo)
12
+ - Wikipedia via API oficial (conteúdo completo)
13
+ - Clima via OpenWeatherMap API (com fallback para wttr.in)
14
+ - Pesquisa Autônoma: AI decide QUANDO e O QUE buscar sem comando explícito
15
+ - Raspagem profunda de página web com extração de conteúdo limpo
16
+ - Cache TTL inteligente por tipo de busca
17
+ - Rate limiting respeitoso e rotação de User-Agent
18
+ - Integração direta com banco de dados (salva pesquisas para RAG)
19
+
20
+ Uso:
21
+ ws = WebSearch(db=db_instance)
22
+ resultado = ws.pesquisar("capital de angola")
23
+ conteudo = ws.buscar_conteudo_completo("presidente João Lourenço")
24
+ deve_ir = ws.deve_buscar_na_web("quem ganhou a copa ontem?")
25
+
26
+ ================================================================================
27
+ """
28
+
29
+ import os
30
+ import re
31
+
32
+ import random
33
+ import time
34
+ import hashlib
35
+ import sqlite3
36
+ import json
37
+ from dataclasses import dataclass
38
+ from typing import Dict, Any, List, Optional, Tuple, Union
39
+ from datetime import datetime
40
+ from loguru import logger
41
+
42
+ try:
43
+ from .config import DB_PATH
44
+ except (ImportError, ValueError):
45
+ try:
46
+ from modules.config import DB_PATH
47
+ except ImportError:
48
+ DB_PATH = "akira.db"
49
+
50
+ # ============================================================
51
+ # Imports opcionais com fallbacks
52
+ # ============================================================
53
+
54
+ try:
55
+ from ddgs import DDGS # type: ignore
56
+ DDGS_AVAILABLE = True
57
+ except ImportError:
58
+ try:
59
+ from duckduckgo_search import DDGS # type: ignore # nome antigo
60
+ DDGS_AVAILABLE = True
61
+ except ImportError:
62
+ DDGS_AVAILABLE = False
63
+ DDGS = None # type: ignore
64
+
65
+ try:
66
+ import requests # type: ignore
67
+ REQUESTS_AVAILABLE = True
68
+ except ImportError:
69
+ REQUESTS_AVAILABLE = False
70
+ requests = None # type: ignore
71
+
72
+ try:
73
+ from bs4 import BeautifulSoup # type: ignore
74
+ BS4_AVAILABLE = True
75
+ except ImportError:
76
+ BS4_AVAILABLE = False
77
+ BeautifulSoup = None # type: ignore
78
+
79
+ try:
80
+ from loguru import logger # type: ignore
81
+ except ImportError:
82
+ class _DummyLogger:
83
+ def info(self, *a, **k): pass
84
+ def success(self, *a, **k): pass
85
+ def warning(self, *a, **k): pass
86
+ def error(self, *a, **k): pass
87
+ def debug(self, *a, **k): pass
88
+ logger = _DummyLogger() # type: ignore
89
+
90
+ try:
91
+ from cachetools import TTLCache # type: ignore
92
+ _CacheOK = True
93
+ except ImportError:
94
+ _CacheOK = False
95
+ class TTLCache(dict): # type: ignore
96
+ def __init__(self, maxsize=100, ttl=900, **kwargs):
97
+ super().__init__(**kwargs)
98
+ self.maxsize = maxsize
99
+ self.ttl = ttl
100
+ self._ts: Dict[str, float] = {}
101
+
102
+ def __setitem__(self, key, value):
103
+ super().__setitem__(key, value)
104
+ self._ts[key] = time.time()
105
+ if len(self) > self.maxsize:
106
+ oldest = min(self._ts, key=lambda k: self._ts[k])
107
+ self.pop(oldest, None)
108
+ self._ts.pop(oldest, None)
109
+
110
+ def get(self, key, default=None):
111
+ if key in self._ts and time.time() - self._ts[key] > self.ttl:
112
+ self.pop(key, None)
113
+ self._ts.pop(key, None)
114
+ return default
115
+ return super().get(key, default)
116
+
117
+ # ============================================================
118
+ # CONFIGURAÇÕES GLOBAIS
119
+ # ============================================================
120
+
121
+ REQUEST_TIMEOUT = 12
122
+
123
+ # Cache com diferentes TTLs por tipo (segundos)
124
+ _CACHE_GERAL = TTLCache(maxsize=60, ttl=900) # 15 min
125
+ _CACHE_NOTICIAS= TTLCache(maxsize=30, ttl=300) # 5 min (notícias mudam rápido)
126
+ _CACHE_WIKI = TTLCache(maxsize=50, ttl=3600) # 1h (Wikipedia é estável)
127
+ _CACHE_CLIMA = TTLCache(maxsize=20, ttl=600) # 10 min
128
+
129
+ USER_AGENTS = [
130
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
131
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
132
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
133
+ ]
134
+
135
+ OPENWEATHER_KEY = os.getenv("OPENWEATHER_API_KEY", "")
136
+
137
+ # Palavras-gatilho para busca autônoma (contexto NLP)
138
+ _TRIGGERS_BUSCA = [
139
+ # Comandos explícitos
140
+ "pesquisa", "busca na web", "buscar na internet", "pesquise",
141
+ "me busca", "google", "procura",
142
+ # Eventos atuais
143
+ "o que está acontecendo", "últimas notícias", "notícias de hoje",
144
+ "o que aconteceu", "aconteceu", "novidades",
145
+ # Perguntas factuais específicas
146
+ "quem é o presidente", "qual é a população", "quantos habitantes",
147
+ "qual a capital", "onde fica", "quando foi fundado",
148
+ # Sports/resultados
149
+ "placar", "resultado do jogo", "ganhou a copa", "eliminado",
150
+ # Temporal
151
+ "ontem", "esta semana", "esse mês", "ano passado", "2025", "2026",
152
+ # Pessoas
153
+ "morreu", "foi preso", "foi assassinado", "renunciou", "eleito",
154
+ # Tempo/clima
155
+ "vai chover", "temperatura em", "clima em", "previsão do tempo",
156
+ ]
157
+
158
+ _PERGUNTAS_FATOS = [
159
+ "?", "quem", "qual", "quando", "onde", "quanto", "quantos",
160
+ "por que", "como é", "o que é", "me conta", "explica",
161
+ ]
162
+
163
+
164
+ # ============================================================
165
+ # CLASSE PRINCIPAL
166
+ # ============================================================
167
+ @dataclass
168
+ class WebSearchConfig:
169
+ db_path: str = DB_PATH
170
+
171
+ class WebSearch:
172
+ """
173
+ Motor de busca autônoma profissional para AKIRA.
174
+
175
+ Prioridade de backends:
176
+ 1. DDGS (duckduckgo-search) - principal, sem API key
177
+ 2. Wikipedia API - para perguntas conceituais
178
+ 3. OpenWeatherMap - para clima
179
+ 4. Scraping direto via BeautifulSoup - fallback
180
+ """
181
+
182
+ def __init__(self, db=None):
183
+ """
184
+ Args:
185
+ db: Instância do Database para persistência das buscas (opcional)
186
+ """
187
+ self.db = db
188
+ self._session = None
189
+ self._setup_session()
190
+
191
+ if DDGS_AVAILABLE:
192
+ logger.success("🔍 WebSearch: DDGS (DuckDuckGo) disponível e ativo")
193
+ else:
194
+ logger.warning("⚠️ WebSearch: ddgs não instalado – fallback via scraping")
195
+
196
+ def _setup_session(self):
197
+ """Configura sessão HTTP com headers realistas."""
198
+ if not REQUESTS_AVAILABLE:
199
+ return
200
+ self._session = requests.Session()
201
+ self._session.headers.update({
202
+ "User-Agent": random.choice(USER_AGENTS),
203
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
204
+ "Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8",
205
+ "Accept-Encoding": "gzip, deflate",
206
+ "Connection": "keep-alive",
207
+ })
208
+
209
+ def _rotate_ua(self):
210
+ """Rotaciona User-Agent para evitar bloqueio."""
211
+ if self._session:
212
+ self._session.headers["User-Agent"] = random.choice(USER_AGENTS)
213
+
214
+ # ==================================================================
215
+ # 🌐 INTERFACE PRINCIPAL
216
+ # ==================================================================
217
+
218
+ def pesquisar(
219
+ self,
220
+ query: str,
221
+ num_results: int = 5,
222
+ tipo: Optional[str] = None,
223
+ ) -> Dict[str, Any]:
224
+ """
225
+ Pesquisa completa com detecção automática de tipo.
226
+
227
+ Args:
228
+ query: Termo de pesquisa
229
+ num_results: Número de resultados (max 10)
230
+ tipo: Forçar tipo: 'geral'|'noticias'|'wikipedia'|'clima'|'imagens'
231
+
232
+ Returns:
233
+ Dict com 'conteudo_bruto', 'resumo', 'tipo', 'resultados'
234
+ """
235
+ if not query or not query.strip():
236
+ return self._erro("Query vazia")
237
+
238
+ query = query.strip()
239
+ cache_key = hashlib.md5(f"{query}:{num_results}:{tipo}".encode()).hexdigest()[:16]
240
+
241
+ # Detecta tipo se não especificado
242
+ tipo_detectado = tipo or self.detectar_tipo_pesquisa(query)
243
+
244
+ # Verifica cache específico por tipo
245
+ cache = self._get_cache(tipo_detectado)
246
+ cached = cache.get(cache_key)
247
+ if cached:
248
+ logger.debug(f"📦 Cache hit [{tipo_detectado}]: {query[:40]}")
249
+ return cached
250
+
251
+ # Rotaciona UA
252
+ self._rotate_ua()
253
+
254
+ # Executa busca pelo tipo
255
+ resultado: Dict[str, Any]
256
+ if tipo_detectado == "wikipedia":
257
+ resultado = self._buscar_wikipedia(query)
258
+ elif tipo_detectado == "noticias":
259
+ resultado = self._buscar_noticias(query, num_results)
260
+ elif tipo_detectado == "clima":
261
+ resultado = self._buscar_clima(query)
262
+ elif tipo_detectado == "imagens":
263
+ resultado = self._buscar_imagens(query, num_results)
264
+ else:
265
+ resultado = self._buscar_texto_ddgs(query, num_results)
266
+
267
+ # Salva no cache
268
+ cache[cache_key] = resultado
269
+
270
+ # Persiste no banco de dados para RAG futuro
271
+ self._persistir_busca(query, tipo_detectado, resultado)
272
+
273
+ return resultado
274
+
275
+ def buscar_conteudo_completo(self, query: str) -> str:
276
+ """Retorna string bruta pronta para inserir no prompt."""
277
+ r = self.pesquisar(query)
278
+ return r.get("conteudo_bruto", "Sem resultados disponíveis.")
279
+
280
+ def buscar_resumido(self, query: str) -> str:
281
+ r = self.pesquisar(query, num_results=3)
282
+ return r.get("resumo", "Sem resumo disponível.")
283
+
284
+ # ==================================================================
285
+ # 🤖 PESQUISA AUTÔNOMA – a IA decide sozinha se deve buscar
286
+ # ==================================================================
287
+
288
+ def deve_buscar_na_web(self, mensagem: str, historico: Optional[List[str]] = None) -> bool:
289
+ """
290
+ Decisão autônoma: a AKIRA deve buscar na web por conta própria?
291
+
292
+ Lógica em camadas:
293
+ 1. Gatilhos explícitos (o usuário pediu)
294
+ 2. Perguntas factuais com marcadores temporais
295
+ 3. Tópicos que o modelo definitivamente não sabe (eventos pós-treino)
296
+ 4. Palavras de eventos conhecidos recentes
297
+
298
+ Args:
299
+ mensagem: Última mensagem do usuário
300
+ historico: Últimas mensagens do histórico (contexto adicional)
301
+
302
+ Returns:
303
+ True se deve pesquisar na web
304
+ """
305
+ msg = mensagem.lower().strip()
306
+
307
+ # 1. Gatilhos explícitos
308
+ if any(t in msg for t in _TRIGGERS_BUSCA):
309
+ logger.info(f"🔍 Pesquisa autônoma ativada [gatilho explícito]: {msg[:60]}")
310
+ return True
311
+
312
+ # 2. Pergunta + indicador temporal/factual
313
+ is_pergunta = (
314
+ "?" in msg or
315
+ any(msg.startswith(p) for p in _PERGUNTAS_FATOS)
316
+ )
317
+ indicadores_atuais = [
318
+ "atual", "recente", "novo", "último", "agora",
319
+ "hoje", "ontem", "semana", "mês", "2024", "2025", "2026",
320
+ "presidente", "governo", "eleição", "guerra", "acordo",
321
+ "crise", "epidemia", "terremoto", "furacão"
322
+ ]
323
+ if is_pergunta and any(p in msg for p in indicadores_atuais):
324
+ logger.info(f"🔍 Pesquisa autônoma ativada [pergunta+temporal]: {msg[:60]}")
325
+ return True
326
+
327
+ # 3. Pessoa pede para contar/explicar com contexto que muda
328
+ frases_dinamicas = [
329
+ "me conta sobre", "o que você sabe sobre", "quem é",
330
+ "o que é", "me fala sobre", "sabes de", "sabe de"
331
+ ]
332
+ if any(f in msg for f in frases_dinamicas):
333
+ # Verifica se é sobre algo que pode ser evento recente
334
+ entidades_suspeitas = msg.split()
335
+ # Heurística: mais de 1 palavra após a frase → provavelmente nome próprio
336
+ for frase in frases_dinamicas:
337
+ if frase in msg:
338
+ pos = msg.find(frase) + len(frase)
339
+ resto = msg[pos:].strip()
340
+ if len(resto.split()) >= 1:
341
+ logger.info(f"🔍 Pesquisa autônoma ativada [entidade]: {resto[:60]}")
342
+ return True
343
+
344
+ # 4. Contexto do histórico (se usuário estava pedindo info antes)
345
+ if historico and isinstance(historico, list):
346
+ try:
347
+ # Conversão ultra-segura: ignora None, extrai de tupla/dict ou converte str
348
+ historico_limpo = []
349
+ for h in historico[-5:]:
350
+ if h is None: continue
351
+ if isinstance(h, tuple) and len(h) > 0:
352
+ historico_limpo.append(str(h[0]))
353
+ elif isinstance(h, dict):
354
+ historico_limpo.append(str(h.get('content', h.get('mensagem', ''))))
355
+ else:
356
+ historico_limpo.append(str(h))
357
+
358
+ ultima_5 = " ".join(historico_limpo).lower()
359
+ if any(t in ultima_5 for t in ["pesquisa", "busca", "notícia", "aconteceu", "saber sobre"]):
360
+ return True
361
+ except Exception as e:
362
+ logger.warning(f"Erro ao processar histórico na busca: {e}")
363
+
364
+ return False
365
+
366
+ def extrair_assunto_busca(self, mensagem: str) -> str:
367
+ """
368
+ Extrai o assunto principal da mensagem para usar como query.
369
+ Mais inteligente que a versão antiga – usa múltiplas heurísticas.
370
+ """
371
+ msg = mensagem.strip()
372
+ msg_lower = msg.lower()
373
+
374
+ # Padrões de extração em ordem de prioridade
375
+ padroes = [
376
+ r"(?:pesquisa|busca|pesquise|procura|me busca|me fala)\s+(?:sobre|de|a respeito de)?\s*(.+)",
377
+ r"(?:quem é|o que é|o que são|onde fica|qual é|quando foi|como é)\s+(.+)",
378
+ r"(?:me conta|me fala|explica|me explica)\s+(?:sobre|de)?\s*(.+)",
379
+ r"(?:notícia|noticia|novidade)\s+(?:sobre|de)\s*(.+)",
380
+ ]
381
+
382
+ for pat in padroes:
383
+ m = re.search(pat, msg_lower)
384
+ if m:
385
+ resultado = m.group(1).strip().rstrip(".,!?")
386
+ if len(resultado) > 2:
387
+ return resultado
388
+
389
+ # Se é uma pergunta direta, use a mensagem inteira mas limpa
390
+ stopwords = ["pesquisa", "busca", "buscar", "procura", "me", "por favor", "pf", "pfv"]
391
+ tokens = msg_lower.split()
392
+ tokens_limpos = [t for t in tokens if t not in stopwords]
393
+
394
+ return " ".join(tokens_limpos) if tokens_limpos else msg
395
+
396
+ # ==================================================================
397
+ # 🎯 DETECÇÃO DE TIPO
398
+ # ==================================================================
399
+
400
+ def detectar_tipo_pesquisa(self, query: str) -> str:
401
+ """
402
+ Detecta automaticamente o melhor tipo de busca para a query.
403
+
404
+ Returns:
405
+ 'wikipedia' | 'noticias' | 'clima' | 'imagens' | 'geral'
406
+ """
407
+ q = query.lower()
408
+
409
+ # Clima
410
+ clima_kws = ["clima", "tempo", "temperatura", "vai chover", "previsão", "chuva", "sol", "humidade"]
411
+ if any(k in q for k in clima_kws):
412
+ return "clima"
413
+
414
+ # Notícias – eventos atuais
415
+ news_kws = [
416
+ "notícia", "noticia", "última hora", "breaking", "aconteceu",
417
+ "hoje", "eleição", "guerra", "crise", "julgamento",
418
+ "preso", "morreu", "assassinado", "renunciou", "ganhou"
419
+ ]
420
+ if any(k in q for k in news_kws):
421
+ return "noticias"
422
+
423
+ # Imagens
424
+ img_kws = ["foto de", "imagem de", "fotos de", "imagens de", "como é", "me mostra"]
425
+ if any(k in q for k in img_kws):
426
+ return "imagens"
427
+
428
+ # IMPORTANTE: Desativada a rota 'wikipedia' pois estava dando erro lib/HTTP.
429
+ # Agora perguntas que seriam wiki (biografias, o que é) caem na busca geral
430
+ # que já raspa o extract de boas fontes.
431
+
432
+ return "geral"
433
+
434
+ # ==================================================================
435
+ # 📰 BUSCA DE TEXTO VIA DDGS (principal)
436
+ # ==================================================================
437
+
438
+ def _buscar_texto_ddgs(self, query: str, num: int = 5) -> Dict[str, Any]:
439
+ """Busca geral usando a biblioteca DDGS (DuckDuckGo Search)."""
440
+ if not DDGS_AVAILABLE:
441
+ return self._buscar_texto_fallback(query, num)
442
+
443
+ try:
444
+ resultados = []
445
+ with DDGS() as ddgs:
446
+ for r in ddgs.text(
447
+ query,
448
+ region="wt-wt",
449
+ safesearch="off",
450
+ timelimit=None,
451
+ max_results=num,
452
+ ):
453
+ resultados.append({
454
+ "titulo": r.get("title", ""),
455
+ "url": r.get("href", ""),
456
+ "snippet": r.get("body", ""),
457
+ })
458
+
459
+ if not resultados:
460
+ return self._erro("DDGS: nenhum resultado")
461
+
462
+ # Tenta enriquecer com conteúdo das páginas
463
+ for res in resultados[:2]: # Só as 2 primeiras para não overload
464
+ conteudo = self._raspar_pagina(res["url"])
465
+ if conteudo:
466
+ res["conteudo_pagina"] = conteudo[:2000]
467
+
468
+ bruto = self._montar_bruto_geral(query, resultados)
469
+ return {
470
+ "tipo": "geral",
471
+ "query": query,
472
+ "resumo": f"Web Search: '{query}' – {len(resultados)} resultados",
473
+ "conteudo_bruto": bruto,
474
+ "resultados": resultados,
475
+ "timestamp": datetime.now().isoformat(),
476
+ "fonte": "ddgs",
477
+ }
478
+
479
+ except Exception as e:
480
+ logger.warning(f"DDGS texto error: {e}")
481
+ return self._buscar_texto_fallback(query, num)
482
+
483
+ # ==================================================================
484
+ # 📰 BUSCA DE NOTÍCIAS VIA DDGS
485
+ # ==================================================================
486
+
487
+ def _buscar_noticias(self, query: str, num: int = 5) -> Dict[str, Any]:
488
+ """Busca notícias usando DDGS News backend."""
489
+ if not DDGS_AVAILABLE:
490
+ return self._buscar_texto_ddgs(query, num) # fallback para geral
491
+
492
+ try:
493
+ noticias = []
494
+ with DDGS() as ddgs:
495
+ for r in ddgs.news(
496
+ query,
497
+ region="wt-wt",
498
+ safesearch="off",
499
+ timelimit="w", # última semana
500
+ max_results=num,
501
+ ):
502
+ noticias.append({
503
+ "titulo": r.get("title", ""),
504
+ "url": r.get("url", ""),
505
+ "snippet": r.get("body", ""),
506
+ "fonte": r.get("source", ""),
507
+ "data": r.get("date", ""),
508
+ })
509
+
510
+ if not noticias:
511
+ # Tenta sem filtro de tempo
512
+ with DDGS() as ddgs:
513
+ for r in ddgs.news(query, max_results=num):
514
+ noticias.append({
515
+ "titulo": r.get("title", ""),
516
+ "url": r.get("url", ""),
517
+ "snippet": r.get("body", ""),
518
+ "fonte": r.get("source", ""),
519
+ "data": r.get("date", ""),
520
+ })
521
+
522
+ if not noticias:
523
+ return self._erro("Noticias: sem resultados")
524
+
525
+ bruto = f"=== 📰 NOTÍCIAS: {query.upper()} ===\n"
526
+ bruto += f"DATA DA BUSCA: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
527
+ for i, n in enumerate(noticias, 1):
528
+ bruto += f"[{i}] {n['titulo']}\n"
529
+ if n.get("fonte"):
530
+ bruto += f" Fonte: {n['fonte']}"
531
+ if n.get("data"):
532
+ bruto += f" | Data: {n['data']}"
533
+ bruto += "\n"
534
+ if n.get("snippet"):
535
+ bruto += f" {n['snippet'][:300]}\n"
536
+ if n.get("url"):
537
+ bruto += f" 🔗 {n['url']}\n"
538
+ bruto += "\n"
539
+ bruto += "--- FIM DAS NOTÍCIAS ---\n"
540
+
541
+ return {
542
+ "tipo": "noticias",
543
+ "query": query,
544
+ "resumo": f"Notícias sobre '{query}': {len(noticias)} encontradas",
545
+ "conteudo_bruto": bruto,
546
+ "resultados": noticias,
547
+ "timestamp": datetime.now().isoformat(),
548
+ "fonte": "ddgs_news",
549
+ }
550
+
551
+ except Exception as e:
552
+ logger.warning(f"DDGS noticias error: {e}")
553
+ return self._buscar_texto_ddgs(query, num)
554
+
555
+ # ==================================================================
556
+ # 📚 WIKIPEDIA
557
+ # ==================================================================
558
+
559
+ def _buscar_wikipedia(self, query: str) -> Dict[str, Any]:
560
+ """Busca na Wikipedia PT via API oficial com extração completa."""
561
+ if not REQUESTS_AVAILABLE:
562
+ return self._erro("Wikipedia: requests não disponível")
563
+
564
+ try:
565
+ # 1. Pesquisa para encontrar o artigo correto
566
+ search_url = "https://pt.wikipedia.org/w/api.php"
567
+ r = self._session.get(search_url, params={
568
+ "action": "query",
569
+ "format": "json",
570
+ "list": "search",
571
+ "srsearch": query,
572
+ "srlimit": 3,
573
+ }, timeout=REQUEST_TIMEOUT)
574
+
575
+ if r.status_code != 200:
576
+ return self._erro(f"Wikipedia HTTP {r.status_code}")
577
+
578
+ data = r.json()
579
+ resultados = data.get("query", {}).get("search", [])
580
+ if not resultados:
581
+ return self._erro("Wikipedia: nenhuma página encontrada")
582
+
583
+ # Pega o mais relevante
584
+ page_title = resultados[0]["title"]
585
+
586
+ # 2. Busca conteúdo completo da página
587
+ r2 = self._session.get(search_url, params={
588
+ "action": "query",
589
+ "format": "json",
590
+ "prop": "extracts|info",
591
+ "exintro": False,
592
+ "explaintext": True,
593
+ "titles": page_title,
594
+ "inprop": "url",
595
+ }, timeout=REQUEST_TIMEOUT)
596
+
597
+ data2 = r2.json()
598
+ pages = data2.get("query", {}).get("pages", {})
599
+ page = next(iter(pages.values()), {})
600
+
601
+ extract = page.get("extract", "")
602
+ fullurl = page.get("fullurl", f"https://pt.wikipedia.org/wiki/{page_title.replace(' ', '_')}")
603
+
604
+ # Limpa e formata
605
+ extract = re.sub(r'\[\d+\]', '', extract)
606
+ extract = re.sub(r'\s+', ' ', extract).strip()
607
+
608
+ bruto = f"=== 📚 WIKIPEDIA: {page_title} ===\n"
609
+ bruto += f"Fonte: {fullurl}\n"
610
+ bruto += f"Data da consulta: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
611
+ bruto += "CONTEÚDO:\n"
612
+ bruto += extract[:6000]
613
+ bruto += "\n\n--- FIM WIKIPEDIA ---\n"
614
+
615
+ return {
616
+ "tipo": "wikipedia",
617
+ "titulo": page_title,
618
+ "url": fullurl,
619
+ "resumo": f"Wikipedia: {page_title}",
620
+ "conteudo_bruto": bruto,
621
+ "timestamp": datetime.now().isoformat(),
622
+ "fonte": "wikipedia_api",
623
+ }
624
+
625
+ except Exception as e:
626
+ logger.warning(f"Wikipedia error: {e}")
627
+ return self._erro(f"Wikipedia: {e}")
628
+
629
+ # ==================================================================
630
+ # 🌤️ CLIMA
631
+ # ==================================================================
632
+
633
+ def _buscar_clima(self, query: str) -> Dict[str, Any]:
634
+ """
635
+ Busca clima via OpenWeatherMap (se API key disponível)
636
+ ou via wttr.in (sempre disponível, sem key).
637
+ """
638
+ # Extrai cidade da query
639
+ cidade = self._extrair_cidade(query)
640
+
641
+ # Tenta wttr.in (sempre gratuito)
642
+ try:
643
+ if self._session:
644
+ url = f"https://wttr.in/{cidade}?format=j1&lang=pt"
645
+ r = self._session.get(url, timeout=REQUEST_TIMEOUT)
646
+ if r.status_code == 200:
647
+ data = r.json()
648
+ cc = data.get("current_condition", [{}])[0]
649
+ area = data.get("nearest_area", [{}])[0]
650
+ nome_area = area.get("areaName", [{}])[0].get("value", cidade)
651
+ pais = area.get("country", [{}])[0].get("value", "")
652
+
653
+ temp_c = cc.get("temp_C", "?")
654
+ sensacao = cc.get("FeelsLikeC", "?")
655
+ humidade = cc.get("humidity", "?")
656
+ vento_kmh = cc.get("windspeedKmph", "?")
657
+ descricao = cc.get("weatherDesc", [{}])[0].get("value", "")
658
+
659
+ bruto = f"=== 🌤️ CLIMA: {nome_area}, {pais} ===\n"
660
+ bruto += f"Data: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
661
+ bruto += f"🌡️ Temperatura atual: {temp_c}°C (sensação: {sensacao}°C)\n"
662
+ bruto += f"💧 Humidade: {humidade}%\n"
663
+ bruto += f"💨 Vento: {vento_kmh} km/h\n"
664
+ bruto += f"☁️ Condição: {descricao}\n"
665
+ bruto += "\n--- FIM CLIMA ---\n"
666
+
667
+ return {
668
+ "tipo": "clima",
669
+ "cidade": nome_area,
670
+ "resumo": f"Clima em {nome_area}: {temp_c}°C, {descricao}",
671
+ "conteudo_bruto": bruto,
672
+ "temperatura": temp_c,
673
+ "timestamp": datetime.now().isoformat(),
674
+ "fonte": "wttr.in",
675
+ }
676
+ except Exception as e:
677
+ logger.warning(f"wttr.in error: {e}")
678
+
679
+ # Fallback: OpenWeatherMap se key disponível
680
+ if OPENWEATHER_KEY:
681
+ return self._clima_openweather(cidade)
682
+
683
+ return self._erro(f"Clima: não foi possível obter dados para '{cidade}'")
684
+
685
+ def _clima_openweather(self, cidade: str) -> Dict[str, Any]:
686
+ """Fallback via OpenWeatherMap API."""
687
+ try:
688
+ url = "https://api.openweathermap.org/data/2.5/weather"
689
+ r = self._session.get(url, params={
690
+ "q": cidade,
691
+ "appid": OPENWEATHER_KEY,
692
+ "units": "metric",
693
+ "lang": "pt",
694
+ }, timeout=REQUEST_TIMEOUT)
695
+
696
+ if r.status_code != 200:
697
+ return self._erro(f"OpenWeather HTTP {r.status_code}")
698
+
699
+ data = r.json()
700
+ temp = data["main"]["temp"]
701
+ sensacao = data["main"]["feels_like"]
702
+ humidade = data["main"]["humidity"]
703
+ vento = data["wind"]["speed"] * 3.6 # m/s → km/h
704
+ desc = data["weather"][0]["description"]
705
+ nome = data.get("name", cidade)
706
+
707
+ bruto = f"=== 🌤️ CLIMA: {nome} ===\n"
708
+ bruto += f"Temperatura: {temp:.1f}°C (sensação: {sensacao:.1f}°C)\n"
709
+ bruto += f"Humidade: {humidade}%\n"
710
+ bruto += f"Vento: {vento:.1f} km/h\n"
711
+ bruto += f"Condição: {desc.capitalize()}\n"
712
+ bruto += "--- FIM CLIMA ---\n"
713
+
714
+ return {
715
+ "tipo": "clima", "cidade": nome,
716
+ "resumo": f"Clima em {nome}: {temp}°C, {desc}",
717
+ "conteudo_bruto": bruto,
718
+ "timestamp": datetime.now().isoformat(),
719
+ "fonte": "openweathermap",
720
+ }
721
+ except Exception as e:
722
+ return self._erro(f"OpenWeather: {e}")
723
+
724
+ # ==================================================================
725
+ # 🖼️ IMAGENS VIA DDGS
726
+ # ==================================================================
727
+
728
+ def _buscar_imagens(self, query: str, num: int = 5) -> Dict[str, Any]:
729
+ """Busca URLs de imagens via DDGS."""
730
+ if not DDGS_AVAILABLE:
731
+ return self._erro("DDGS não disponível para imagens")
732
+
733
+ try:
734
+ imagens = []
735
+ with DDGS() as ddgs:
736
+ for r in ddgs.images(
737
+ query,
738
+ region="wt-wt",
739
+ safesearch="off",
740
+ size=None,
741
+ max_results=num,
742
+ ):
743
+ imagens.append({
744
+ "titulo": r.get("title", ""),
745
+ "url_imagem": r.get("image", ""),
746
+ "url_pagina": r.get("url", ""),
747
+ "thumbnail": r.get("thumbnail", ""),
748
+ "fonte": r.get("source", ""),
749
+ })
750
+
751
+ if not imagens:
752
+ return self._erro("Imagens: sem resultados")
753
+
754
+ bruto = f"=== 🖼️ IMAGENS: {query} ===\n"
755
+ bruto += f"Data: {datetime.now().strftime('%d/%m/%Y')}\n\n"
756
+ for i, img in enumerate(imagens, 1):
757
+ bruto += f"[{i}] {img['titulo']}\n"
758
+ bruto += f" URL: {img['url_imagem']}\n"
759
+ if img.get("fonte"):
760
+ bruto += f" Fonte: {img['fonte']}\n"
761
+ bruto += "\n"
762
+ bruto += "--- FIM IMAGENS ---\n"
763
+
764
+ return {
765
+ "tipo": "imagens",
766
+ "query": query,
767
+ "resumo": f"Imagens de '{query}': {len(imagens)} encontradas",
768
+ "conteudo_bruto": bruto,
769
+ "resultados": imagens,
770
+ "timestamp": datetime.now().isoformat(),
771
+ "fonte": "ddgs_images",
772
+ }
773
+
774
+ except Exception as e:
775
+ logger.warning(f"DDGS imagens error: {e}")
776
+ return self._erro(f"Imagens: {e}")
777
+
778
+ # ==================================================================
779
+ # 🔄 FALLBACK – Scraping manual via BeautifulSoup
780
+ # ==================================================================
781
+
782
+ def _buscar_texto_fallback(self, query: str, num: int = 5) -> Dict[str, Any]:
783
+ """Fallback: scraping HTML do DuckDuckGo se DDGS não estiver instalado."""
784
+ if not REQUESTS_AVAILABLE or not BS4_AVAILABLE:
785
+ return self._erro("Dependências insuficientes para busca fallback")
786
+
787
+ try:
788
+ from urllib.parse import urlencode
789
+ url = f"https://html.duckduckgo.com/html/?{urlencode({'q': query, 'kl': 'pt-pt'})}"
790
+ r = self._session.get(url, timeout=REQUEST_TIMEOUT)
791
+
792
+ if r.status_code != 200:
793
+ return self._erro(f"DuckDuckGo HTML: HTTP {r.status_code}")
794
+
795
+ soup = BeautifulSoup(r.text, "html.parser")
796
+ resultados = []
797
+ for res in soup.find_all("div", class_="result")[:num]:
798
+ a = res.find("a", class_="result__a")
799
+ snip = res.find("a", class_="result__snippet")
800
+ if a:
801
+ resultados.append({
802
+ "titulo": a.get_text(strip=True),
803
+ "url": a.get("href", ""),
804
+ "snippet": snip.get_text(strip=True) if snip else "",
805
+ })
806
+
807
+ if not resultados:
808
+ return self._erro("Fallback: sem resultados")
809
+
810
+ bruto = self._montar_bruto_geral(query, resultados)
811
+ return {
812
+ "tipo": "geral",
813
+ "query": query,
814
+ "resumo": f"Web: '{query}' – {len(resultados)} resultados",
815
+ "conteudo_bruto": bruto,
816
+ "resultados": resultados,
817
+ "timestamp": datetime.now().isoformat(),
818
+ "fonte": "scraping_fallback",
819
+ }
820
+
821
+ except Exception as e:
822
+ return self._erro(f"Fallback: {e}")
823
+
824
+ # ==================================================================
825
+ # 🌐 RASPAGEM DE CONTEÚDO DE PÁGINA
826
+ # ==================================================================
827
+
828
+ def _raspar_pagina(self, url: str) -> str:
829
+ """
830
+ Extrai conteúdo relevante de uma URL.
831
+ Retorna texto limpo ou string vazia se falhar.
832
+ """
833
+ if not REQUESTS_AVAILABLE or not BS4_AVAILABLE or not url:
834
+ return ""
835
+
836
+ # Evita PDFs, binários, etc.
837
+ ignorar = [".pdf", ".doc", ".xls", ".zip", ".exe", "javascript:", "mailto:"]
838
+ if any(url.lower().endswith(ext) or ext in url.lower() for ext in ignorar):
839
+ return ""
840
+
841
+ try:
842
+ r = self._session.get(url, timeout=8)
843
+ if r.status_code != 200:
844
+ return ""
845
+
846
+ soup = BeautifulSoup(r.text, "html.parser")
847
+
848
+ # Remove scripts, style, nav, footer
849
+ for tag in soup.find_all(["script", "style", "nav", "footer", "header", "aside"]):
850
+ tag.decompose()
851
+
852
+ # Tenta encontrar conteúdo principal
853
+ main_content = (
854
+ soup.find("article") or
855
+ soup.find("main") or
856
+ soup.find("div", {"id": re.compile(r"content|main|article", re.I)}) or
857
+ soup.find("div", {"class": re.compile(r"content|main|article|post", re.I)})
858
+ )
859
+
860
+ if main_content:
861
+ texto = main_content.get_text(separator=" ", strip=True)
862
+ else:
863
+ texto = soup.get_text(separator=" ", strip=True)
864
+
865
+ # Limpa espaços excessivos
866
+ texto = re.sub(r"\s+", " ", texto).strip()
867
+ return texto[:3000]
868
+
869
+ except Exception:
870
+ return ""
871
+
872
+ # ==================================================================
873
+ # 🛠️ UTILITÁRIOS
874
+ # ==================================================================
875
+
876
+ def _montar_bruto_geral(self, query: str, resultados: List[Dict]) -> str:
877
+ bruto = f"=== 🔎 PESQUISA WEB: {query.upper()} ===\n"
878
+ bruto += f"Data: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n"
879
+ bruto += f"Total de resultados: {len(resultados)}\n\n"
880
+ for i, r in enumerate(resultados, 1):
881
+ bruto += f"[{i}] {r.get('titulo', 'Sem título')}\n"
882
+ bruto += f" 🔗 {r.get('url', '')}\n"
883
+ if r.get("snippet"):
884
+ bruto += f" {r['snippet'][:400]}\n"
885
+ if r.get("conteudo_pagina"):
886
+ bruto += f" [CONTEÚDO] {r['conteudo_pagina'][:800]}\n"
887
+ bruto += "\n"
888
+ bruto += "--- FIM DOS RESULTADOS ---\n"
889
+ return bruto
890
+
891
+ def _extrair_cidade(self, query: str) -> str:
892
+ """Extrai nome de cidade de uma query sobre clima."""
893
+ q = query.lower()
894
+ prefixos = ["clima em", "tempo em", "temperatura em", "previsão em", "vai chover em", "como está o tempo em"]
895
+ for p in prefixos:
896
+ if p in q:
897
+ return q.split(p)[-1].strip().split()[0].capitalize()
898
+ # Heurística: última palavra relevante
899
+ tokens = [t for t in query.split() if t.lower() not in
900
+ ["clima", "tempo", "temperatura", "previsão", "hoje", "amanhã", "de", "em", "o", "a"]]
901
+ return tokens[-1].capitalize() if tokens else "Luanda"
902
+
903
+ def _get_cache(self, tipo: str) -> TTLCache:
904
+ if tipo == "noticias":
905
+ return _CACHE_NOTICIAS
906
+ if tipo == "wikipedia":
907
+ return _CACHE_WIKI
908
+ if tipo == "clima":
909
+ return _CACHE_CLIMA
910
+ return _CACHE_GERAL
911
+
912
+ def _persistir_busca(self, query: str, tipo: str, resultado: Dict):
913
+ """Salva a busca no banco para uso como contexto RAG futuro."""
914
+ if not self.db:
915
+ return
916
+ try:
917
+ resumo = resultado.get("resumo", "")
918
+ self.db.salvar_aprendizado_detalhado(
919
+ usuario="sistema",
920
+ chave=f"web_search_{tipo}_{hashlib.md5(query.encode()).hexdigest()[:8]}",
921
+ valor=json.dumps({
922
+ "query": query,
923
+ "tipo": tipo,
924
+ "resumo": resumo,
925
+ "timestamp": datetime.now().isoformat(),
926
+ }, ensure_ascii=False)
927
+ )
928
+ except Exception as e:
929
+ logger.debug(f"Persistência de busca ignorada: {e}")
930
+
931
+ def _erro(self, mensagem: str) -> Dict[str, Any]:
932
+ return {
933
+ "tipo": "erro",
934
+ "resumo": mensagem,
935
+ "conteudo_bruto": f"=== ⚠️ ERRO NA PESQUISA ===\n{mensagem}\n---",
936
+ "timestamp": datetime.now().isoformat(),
937
+ "erro": True,
938
+ }
939
+
940
+ def limpar_cache(self):
941
+ _CACHE_GERAL.clear()
942
+ _CACHE_NOTICIAS.clear()
943
+ _CACHE_WIKI.clear()
944
+ _CACHE_CLIMA.clear()
945
+ logger.info("🧹 Todos os caches de WebSearch limpos")
946
+
947
+
948
+ # ============================================================
949
+ # SINGLETON & HELPERS PÚBLICOS
950
+ # ============================================================
951
+
952
+ _instance: Optional[WebSearch] = None
953
+
954
+
955
+ def get_web_search(db=None) -> WebSearch:
956
+ """Retorna instância singleton do WebSearch."""
957
+ global _instance
958
+ if _instance is None:
959
+ _instance = WebSearch(db=db)
960
+ return _instance
961
+
962
+
963
+ def buscar_na_web(query: str, db=None) -> str:
964
+ """Helper rápido: busca e retorna conteúdo bruto."""
965
+ return get_web_search(db=db).buscar_conteudo_completo(query)
966
+
967
+
968
+ def deve_pesquisar(mensagem: str, historico: Optional[List[str]] = None) -> bool:
969
+ """Helper: decide se deve pesquisar na web."""
970
+ return get_web_search().deve_buscar_na_web(mensagem, historico)
971
+
972
+
973
+ def extrair_pesquisa(mensagem: str) -> str:
974
+ """Helper: extrai assunto de busca da mensagem."""
975
+ return get_web_search().extrair_assunto_busca(mensagem)
976
+
977
+
978
+ __all__ = [
979
+ "WebSearch",
980
+ "get_web_search",
981
+ "buscar_na_web",
982
+ "deve_pesquisar",
983
+ "extrair_pesquisa",
984
+ ]