api.ts
1 import type { SearchParams, SearchResponse, SearchType, SearchResult, Coordinates } from './types'; 2 import { yacyConfig, apiRoutes, defaults, external, timeouts } from './config'; 3 import { 4 determineContentType, 5 extractDomain, 6 formatUrl, 7 formatSize, 8 getThumbnailUrl, 9 truncateText, 10 decodeHtml 11 } from './utils'; 12 13 /** 14 * Clase que encapsula la funcionalidad de la API de búsqueda 15 */ 16 class SearchService { 17 /** 18 * Cache simple para resultados de búsqueda (para minimizar peticiones duplicadas) 19 * La clave es la URL de búsqueda y el valor es {data, timestamp} 20 */ 21 private cache: Map<string, {data: SearchResponse, timestamp: number}> = new Map(); 22 23 /** 24 * Tiempo de vida en milisegundos para entradas en cache 25 */ 26 private readonly CACHE_TTL = 1000 * 60 * 5; // 5 minutos 27 28 /** 29 * Realiza una búsqueda a través de la API 30 * @param params Parámetros de búsqueda 31 * @param useCache Indica si se debe usar la cache 32 * @returns Promesa con los resultados de búsqueda 33 */ 34 async search(params: SearchParams, useCache = true): Promise<SearchResponse> { 35 try { 36 const searchParams = this.buildSearchParams(params); 37 const apiUrl = `${apiRoutes.search}?${searchParams.toString()}`; 38 39 // Si está activado el uso de cache y la búsqueda está en cache y no ha expirado 40 if (useCache && this.cache.has(apiUrl)) { 41 const cachedData = this.cache.get(apiUrl)!; 42 const now = Date.now(); 43 44 // Si los datos en cache aún son válidos (no han expirado) 45 if (now - cachedData.timestamp < this.CACHE_TTL) { 46 console.log(`Usando resultados en cache para: ${params.query} (página ${params.page})`); 47 return cachedData.data; 48 } else { 49 // Eliminar entrada expirada 50 this.cache.delete(apiUrl); 51 } 52 } 53 54 console.log(`Realizando búsqueda con parámetros:`, Object.fromEntries(searchParams.entries())); 55 56 // Configurar timeout y realizar petición 57 const controller = new AbortController(); 58 const timeoutId = setTimeout(() => controller.abort(), timeouts.search); 59 60 const response = await fetch(apiUrl, { 61 signal: controller.signal 62 }); 63 64 clearTimeout(timeoutId); 65 66 if (!response.ok) { 67 throw new Error(`Error en la API de búsqueda: ${response.status} ${response.statusText}`); 68 } 69 70 const data = await response.json(); 71 72 // Almacenar en cache si no hay error 73 if (!data.error && useCache) { 74 this.cache.set(apiUrl, { 75 data: data as SearchResponse, 76 timestamp: Date.now() 77 }); 78 79 // Limpiar cache si es demasiado grande 80 if (this.cache.size > 100) { 81 const oldestKey = this.findOldestCacheEntry(); 82 if (oldestKey) { 83 this.cache.delete(oldestKey); 84 } 85 } 86 } 87 88 return data as SearchResponse; 89 } catch (error) { 90 console.error('Error en búsqueda:', error); 91 throw error; 92 } 93 } 94 95 /** 96 * Encuentra la entrada más antigua en el cache para poder eliminarla 97 * @returns Clave de la entrada más antigua o null si el cache está vacío 98 */ 99 private findOldestCacheEntry(): string | null { 100 if (this.cache.size === 0) return null; 101 102 let oldestKey: string | null = null; 103 let oldestTimestamp = Infinity; 104 105 for (const [key, value] of this.cache.entries()) { 106 if (value.timestamp < oldestTimestamp) { 107 oldestTimestamp = value.timestamp; 108 oldestKey = key; 109 } 110 } 111 112 return oldestKey; 113 } 114 115 /** 116 * Verifica si el servidor YaCy está disponible 117 * @returns Promesa que se resuelve a true si está disponible, false en caso contrario 118 */ 119 async checkYacyAvailability(): Promise<boolean> { 120 try { 121 const controller = new AbortController(); 122 const timeoutId = setTimeout(() => controller.abort(), timeouts.statusCheck); 123 124 const response = await fetch(apiRoutes.yacySearch, { 125 signal: controller.signal 126 }); 127 128 clearTimeout(timeoutId); 129 130 return response.ok; 131 } catch (error) { 132 console.warn('Error al verificar disponibilidad de YaCy:', error); 133 return false; 134 } 135 } 136 137 /** 138 * Construye los parámetros de búsqueda para la API 139 * @param params Parámetros de búsqueda proporcionados 140 * @returns URLSearchParams formateados 141 */ 142 private buildSearchParams(params: SearchParams): URLSearchParams { 143 const searchParams = new URLSearchParams(); 144 145 // Parámetros básicos 146 if (params.query) searchParams.set('query', params.query); 147 if (params.type) searchParams.set('type', params.type); 148 if (params.language) searchParams.set('language', params.language); 149 if (params.page !== undefined) searchParams.set('page', params.page.toString()); 150 151 // Parámetros adicionales 152 if (params.resultadosPorPagina) { 153 searchParams.set('resultadosPorPagina', params.resultadosPorPagina.toString()); 154 } 155 if (params.recurso) searchParams.set('recurso', params.recurso); 156 if (params.preferMask) searchParams.set('preferMask', params.preferMask); 157 if (params.constraints) searchParams.set('constraints', params.constraints); 158 if (params.mediaSearch) searchParams.set('mediaSearch', params.mediaSearch); 159 160 return searchParams; 161 } 162 163 /** 164 * Procesa un resultado de búsqueda para normalizar y enriquecer sus datos 165 * @param rawItem Item en formato raw de la API 166 * @param type Tipo de búsqueda 167 * @returns Item procesado 168 */ 169 processSearchResult(rawItem: any, type: SearchType = 'web'): SearchResult { 170 // Determinar el tipo de contenido 171 const itemType = type === 'images' ? 'images' : determineContentType(rawItem.link, type as SearchType); 172 173 // Obtener miniatura adecuada 174 const thumbnail = getThumbnailUrl(rawItem, itemType as SearchType); 175 176 // Procesamiento para obtener imagen completa (para resultados tipo 'images') 177 let image: string | undefined; 178 if (itemType === 'images') { 179 image = rawItem.image || rawItem.iconlink || rawItem.imagelink || 180 (rawItem.link && determineContentType(rawItem.link) === 'images' ? rawItem.link : null) || 181 thumbnail; 182 } 183 184 // Extraer y formatear información adicional 185 const domain = extractDomain(rawItem.link); 186 const formattedUrl = formatUrl(rawItem.link); 187 const description = decodeHtml(rawItem.snippet || rawItem.description || 'Sin descripción'); 188 189 // Extraer coordenadas para resultados tipo 'maps' 190 const coordinates = this.extractCoordinates(rawItem); 191 192 // Crear objeto de resultado normalizado 193 return { 194 title: rawItem.title || 'Sin título', 195 link: rawItem.link, 196 description, 197 type: itemType as SearchType, 198 thumbnail, 199 image, 200 imageWidth: rawItem.imageWidth || (rawItem.attr?.width ? Number(rawItem.attr.width) : undefined), 201 imageHeight: rawItem.imageHeight || (rawItem.attr?.height ? Number(rawItem.attr.height) : undefined), 202 language: rawItem.language || this.detectLanguage(description), 203 tags: this.extractTags(rawItem), 204 date: rawItem.pubDate ? new Date(rawItem.pubDate) : 205 rawItem.lastModified ? new Date(rawItem.lastModified) : undefined, 206 size: rawItem.size ? formatSize(Number(rawItem.size)) : undefined, 207 score: rawItem.score ? Number(rawItem.score) : undefined, 208 coordinates, 209 domain, 210 formattedUrl 211 }; 212 } 213 214 /** 215 * Extrae coordenadas geográficas de un resultado 216 * @param item Item del que extraer coordenadas 217 * @returns Coordenadas si se encuentran, undefined en caso contrario 218 */ 219 private extractCoordinates(item: any): Coordinates | undefined { 220 // Si el ítem ya tiene coordenadas explícitas 221 if (item.coordinates) { 222 return item.coordinates; 223 } 224 225 // Si el ítem tiene atributos con coordenadas 226 if (item.attr && (item.attr.lat || item.attr.latitude) && (item.attr.lng || item.attr.longitude)) { 227 const lat = parseFloat(item.attr.lat || item.attr.latitude); 228 const lng = parseFloat(item.attr.lng || item.attr.longitude); 229 230 if (!isNaN(lat) && !isNaN(lng)) { 231 return { lat, lng }; 232 } 233 } 234 235 // Buscar en el campo GeoTag de YaCy 236 if (item.geotag) { 237 const parts = item.geotag.split(','); 238 if (parts.length >= 2) { 239 const lat = parseFloat(parts[0]); 240 const lng = parseFloat(parts[1]); 241 242 if (!isNaN(lat) && !isNaN(lng)) { 243 return { lat, lng }; 244 } 245 } 246 } 247 248 // Buscar coordenadas en la descripción utilizando patrón común de coordenadas 249 // Ejemplo: "coordinates: 40.416775, -3.703790" o "lat: 40.416775, lng: -3.703790" 250 if (item.description) { 251 // Patrón para encontrar coordenadas en formatos comunes 252 const coordPatterns = [ 253 /(?:coordinates|coord|position|lat|latitude)[:\s]+(-?\d+\.\d+)[,\s]+(?:lng|long|longitude)?[:\s]*(-?\d+\.\d+)/i, 254 /(-?\d+\.\d+)°\s*[NS]\s*,?\s*(-?\d+\.\d+)°\s*[EW]/i, 255 /ubicación[:\s]+(-?\d+\.\d+)[,\s]+(-?\d+\.\d+)/i 256 ]; 257 258 for (const pattern of coordPatterns) { 259 const match = item.description.match(pattern); 260 if (match && match.length >= 3) { 261 const lat = parseFloat(match[1]); 262 const lng = parseFloat(match[2]); 263 264 if (!isNaN(lat) && !isNaN(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) { 265 return { lat, lng }; 266 } 267 } 268 } 269 } 270 271 return undefined; 272 } 273 274 /** 275 * Extrae tags/etiquetas relevantes del resultado 276 * @param item Item del que extraer tags 277 * @returns Array de tags 278 */ 279 private extractTags(item: any): string[] { 280 const tags: string[] = []; 281 282 // Si el ítem ya tiene tags 283 if (item.tags && Array.isArray(item.tags)) { 284 tags.push(...item.tags); 285 } 286 287 // Detectar tipo de contenido por MIME type 288 if (item.mime) { 289 const mime = item.mime.split('/'); 290 if (mime.length > 1) { 291 tags.push(mime[1].toUpperCase()); 292 } 293 } 294 295 // Añadir autor si está disponible 296 if (item.author) { 297 tags.push(`AUTOR: ${item.author}`); 298 } 299 300 // Fecha de modificación 301 if (item.lastModified) { 302 const date = new Date(item.lastModified); 303 tags.push(date.getFullYear().toString()); 304 } 305 306 // Limitar a máximo 5 tags y eliminar duplicados 307 return [...new Set(tags)].slice(0, 5); 308 } 309 310 /** 311 * Detección básica de idioma basada en palabras clave 312 * @param text Texto del que detectar idioma 313 * @returns Código de idioma detectado o 'unknown' 314 */ 315 private detectLanguage(text: string): string { 316 if (!text) return 'unknown'; 317 318 // Convertir a minúsculas para mejorar detección 319 const lowerText = text.toLowerCase(); 320 321 // Frecuencia de palabras comunes por idioma 322 const es = (lowerText.match(/\b(el|la|los|las|en|que|por|con|para|como|este|esta)\b/g) || []).length; 323 const en = (lowerText.match(/\b(the|and|for|with|this|that|have|from|what|about)\b/g) || []).length; 324 const fr = (lowerText.match(/\b(le|la|les|des|en|que|pour|avec|comme|cette)\b/g) || []).length; 325 326 // Determinar idioma más probable (con un umbral mínimo) 327 const minThreshold = 3; // Mínimo de coincidencias para considerar un idioma 328 329 if (es > en && es > fr && es >= minThreshold) return 'es'; 330 if (en > es && en > fr && en >= minThreshold) return 'en'; 331 if (fr > es && fr > en && fr >= minThreshold) return 'fr'; 332 333 return 'unknown'; 334 } 335 } 336 337 // Exportar una instancia única del servicio API 338 export const searchService = new SearchService(); 339